Files
ai-agent/backend/diff/routes.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
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>
2026-10-06 23:55:47 +02:00

151 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
}