main.py (5146 lines, 106 routes) becomes an assembly only: one package per domain — core, files, settings, dashboards, conversations, diff, notifications, forms, runs, accounts, workers, models, cron, agents, projects, services, goals, memories, plans, templates — each exposing an APIRouter; the flat domain modules move into their package behind a barrel that keeps the old `import conversations` / `import projects` spellings. The shared singletons (store, meta_store, hub, indexer, …) are built once by core.state.build_state() and attached to app.state.ai; routes take them as the `deps: State` dependency and helpers as an explicit `deps: AppState`. conversations/pricing.py carries the per-model rates out of the parser. Verified: route table and OpenAPI byte-identical; 90 read endpoints golden-diffed against the monolith on a copy of the live data (identical); write routes smoke-tested; 66 backend tests pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
151 lines
6.4 KiB
Python
151 lines
6.4 KiB
Python
"""The diff view: a conversation's commits, their diffs and the uncommitted changes."""
|
||
|
||
import pathlib
|
||
import threading
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import Response
|
||
|
||
import schemas
|
||
from conversations.catalog import _resolve_transcript, _summary_and_meta
|
||
from core.http import _r
|
||
from core.state import State
|
||
from diff import editsdiff, gitdiff, worktreediff
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/api/conversation-commits", responses=_r(schemas.ConversationCommits))
|
||
def conversation_commits(deps: State, id: str):
|
||
"""The commits a conversation produced (cached), tagged with their repo."""
|
||
sid, summary, meta = _summary_and_meta(deps, id)
|
||
data = gitdiff.conversation_commits(deps.store, summary, meta, sid)
|
||
return {"id": id, **data}
|
||
|
||
|
||
@router.get("/api/commit-diff", responses=_r(schemas.DiffResult))
|
||
def commit_diff(repo: str, sha: str):
|
||
"""Structured per-file hunk diff of a single commit within a repo token."""
|
||
out = gitdiff.commit_diff(repo, sha)
|
||
if out is None:
|
||
raise HTTPException(404, "commit not found")
|
||
return out
|
||
|
||
|
||
# Content-type for image blobs served in the diff view (the only kind the diff
|
||
# viewer requests). Anything else falls back to octet-stream.
|
||
_IMAGE_CT = {
|
||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||
".avif": "image/avif", ".svg": "image/svg+xml", ".ico": "image/x-icon",
|
||
}
|
||
|
||
|
||
@router.get("/api/diff-blob")
|
||
def diff_blob(repo: str, path: str, sha: str | None = None, wt: str | None = None):
|
||
"""Raw bytes of a file inside a diff — the image side rendered in the viewer.
|
||
|
||
``repo`` is a diff repo token (``project:`` / ``service:`` / ``super:_``),
|
||
``path`` is repo-relative. With ``sha`` the file is read at that rev (e.g. the
|
||
commit's tip for an added image, or ``<sha>^`` for a deleted one); without it
|
||
the current working-tree copy is served (conversation / uncommitted diffs).
|
||
``wt`` names a worktree directory — the file is then read from that worktree
|
||
instead of the canonical checkout (see worktreediff)."""
|
||
data = (worktreediff.blob_bytes(wt, path) if wt
|
||
else gitdiff.blob_bytes(repo, path, sha=sha))
|
||
if data is None:
|
||
raise HTTPException(404, "blob not found")
|
||
ext = pathlib.PurePosixPath(path).suffix.lower()
|
||
ct = _IMAGE_CT.get(ext, "application/octet-stream")
|
||
# SVG can carry scripts; serving it inline as image/svg+xml is fine for <img>
|
||
# but nosniff + no inline-disposition keeps it from being treated as a page.
|
||
return Response(content=data, media_type=ct,
|
||
headers={"X-Content-Type-Options": "nosniff",
|
||
"Cache-Control": "public, max-age=3600"})
|
||
|
||
|
||
# Replaying a transcript's edits re-reads the whole file; during a live run the
|
||
# panel refetches on every SSE ping, so cache per (path, mtime, size, commits).
|
||
_udiff_cache: dict[str, tuple[tuple, dict]] = {}
|
||
_UDIFF_CACHE_CAP = 8
|
||
_udiff_lock = threading.Lock()
|
||
|
||
|
||
@router.get("/api/conversation-uncommitted-diff", responses=_r(schemas.DiffResult))
|
||
def conversation_uncommitted_diff(deps: State, id: str, stats: bool = False):
|
||
"""The conversation's current not-yet-committed changes.
|
||
|
||
When it worked in a git worktree that still exists, this is a **real**
|
||
``git diff HEAD`` (+ untracked files) read from that worktree — see
|
||
worktreediff. Otherwise (or for edits made outside it) the changes are
|
||
replayed from the transcript's accumulated edits/creates/deletes (editsdiff).
|
||
``stats`` strips the hunks — the detail panel only needs paths and +/− counts.
|
||
"""
|
||
sid, summary, meta = _summary_and_meta(deps, id)
|
||
ap = _resolve_transcript(id)
|
||
data = gitdiff.conversation_commits(deps.store, summary, meta, sid)
|
||
wt_out = worktreediff.worktree_diff(summary, meta,
|
||
summary.get("title") or "", stats=stats)
|
||
try:
|
||
st = ap.stat()
|
||
sig = (st.st_mtime, st.st_size,
|
||
tuple(c.get("sha") for c in data["commits"]))
|
||
except OSError:
|
||
sig = None
|
||
out = None
|
||
with _udiff_lock:
|
||
hit = _udiff_cache.get(str(ap))
|
||
if hit and sig and hit[0] == sig:
|
||
out = hit[1]
|
||
if out is None:
|
||
out = editsdiff.uncommitted_diff(ap, summary.get("title") or "",
|
||
data["commits"])
|
||
if sig:
|
||
with _udiff_lock:
|
||
_udiff_cache[str(ap)] = (sig, out)
|
||
while len(_udiff_cache) > _UDIFF_CACHE_CAP:
|
||
_udiff_cache.pop(next(iter(_udiff_cache)))
|
||
if stats:
|
||
out = {**out, "files": [{**f, "hunks": []} for f in out["files"]]}
|
||
if wt_out is None:
|
||
return out
|
||
# A live worktree is authoritative for its own repo, so the replay only
|
||
# contributes what it saw *elsewhere* (e.g. a stray edit in the main
|
||
# checkout) — otherwise reverted or already-committed files would linger.
|
||
covered = {w["repo"] for w in wt_out["worktrees"]}
|
||
extra = [f for f in out["files"] if f.get("repo") not in covered]
|
||
if not extra:
|
||
return wt_out
|
||
files = wt_out["files"] + extra
|
||
wts = wt_out["worktrees"]
|
||
return {**wt_out, "files": files, "approx": True,
|
||
"additions": sum(f["additions"] for f in files),
|
||
"deletions": sum(f["deletions"] for f in files),
|
||
"subtitle": worktreediff.subtitle(wts, len(files), len(extra))}
|
||
|
||
|
||
@router.get("/api/conversation-diff", responses=_r(schemas.DiffResult))
|
||
def conversation_diff(deps: State, id: str):
|
||
"""Aggregated net diff across all of a conversation's commits."""
|
||
sid, summary, meta = _summary_and_meta(deps, id)
|
||
data = gitdiff.conversation_commits(deps.store, summary, meta, sid)
|
||
by_repo: dict[str, list[str]] = {}
|
||
for c in data["commits"]:
|
||
by_repo.setdefault(c["repo"], []).append(c["sha"])
|
||
files: list[dict] = []
|
||
add = dele = 0
|
||
for token, shas in by_repo.items():
|
||
agg = gitdiff.aggregate_diff(token, shas, title="", subtitle="")
|
||
if agg:
|
||
files += agg["files"]
|
||
add += agg["additions"]
|
||
dele += agg["deletions"]
|
||
title = summary.get("title") or "Conversation changes"
|
||
n = len(data["commits"])
|
||
return {
|
||
"title": title,
|
||
"subtitle": f"{n} commit{'s' if n != 1 else ''} · {len(files)} file{'s' if len(files) != 1 else ''}",
|
||
"files": files, "additions": add, "deletions": dele,
|
||
"commits": data["commits"], "approx": True,
|
||
}
|