diff --git a/desktop/src/apps/FilesApp.tsx b/desktop/src/apps/FilesApp.tsx index df8d9fa85..26a14f28f 100644 --- a/desktop/src/apps/FilesApp.tsx +++ b/desktop/src/apps/FilesApp.tsx @@ -34,6 +34,7 @@ import { ContextMenu, type MenuItem } from "@/components/ContextMenu"; import { MobileSplitView } from "@/components/mobile/MobileSplitView"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { resolveAgentEmoji } from "@/lib/agent-emoji"; +import { projectsApi, type DocReviewState } from "@/lib/projects"; import { useDragSource } from "@/shell/dnd/use-drag-source"; /* ------------------------------------------------------------------ */ @@ -297,6 +298,50 @@ function getFileIcon(name: string, isDir: boolean) { return EXT_ICONS[ext] ?? File; } +const REVIEW_BADGE_STYLES: Record = { + approved: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20", + changes_requested: "bg-amber-500/15 text-amber-400 border-amber-500/20", + awaiting_review: "bg-zinc-500/10 text-zinc-400 border-zinc-500/15", +}; + +const REVIEW_BADGE_LABEL: Record = { + approved: "Approved", + changes_requested: "Changes requested", + awaiting_review: "Awaiting review", +}; + +function ReviewBadge({ + state, + onClick, +}: { + state: string | null; + onClick?: () => void; +}) { + if (!state) return null; + const style = REVIEW_BADGE_STYLES[state] ?? REVIEW_BADGE_STYLES.awaiting_review; + const label = REVIEW_BADGE_LABEL[state] ?? state; + if (onClick) { + return ( + + ); + } + return ( + + {label} + + ); +} + /* ------------------------------------------------------------------ */ /* API helpers */ /* ------------------------------------------------------------------ */ @@ -329,6 +374,8 @@ interface FileRowProps { setDeleteConfirm: (path: string | null) => void; onContextMenu: (e: React.MouseEvent, f: FileEntry) => void; isCut: boolean; + reviewState: string | null; + onDocReviewClick?: () => void; } function FileRow({ @@ -342,6 +389,8 @@ function FileRow({ setDeleteConfirm, onContextMenu, isCut, + reviewState, + onDocReviewClick, }: FileRowProps) { const Icon = getFileIcon(f.name, f.is_dir); const relPath = f.path || (currentPath ? `${currentPath}/${f.name}` : f.name); @@ -410,6 +459,11 @@ function FileRow({ {formatDate(f.modified)} + + {!f.is_dir && isProjectLocation(location) && ( + + )} +
{!f.is_dir && isWritable && ( @@ -502,9 +556,50 @@ export function FilesApp({ const [workspaceTrashLoading, setWorkspaceTrashLoading] = useState(false); const [workspaceTrashError, setWorkspaceTrashError] = useState(null); + // Doc-review stamp state (project: locations only) + const [reviewStates, setReviewStates] = useState>({}); + const fileInputRef = useRef(null); const dragCounter = useRef(0); + const fetchReviewStates = useCallback(async (loc: string) => { + if (!isProjectLocation(loc)) { + setReviewStates({}); + return; + } + const pid = projectSlug(loc); + try { + const items = await projectsApi.docReviews.list(pid); + const map: Record = {}; + for (const item of items) { + if (item.review_state) map[item.doc_path] = item.review_state; + } + setReviewStates(map); + } catch { + setReviewStates({}); + } + }, []); + + const cycleDocReview = useCallback( + async (docPath: string, current: string | null) => { + if (!isProjectLocation(location)) return; + const pid = projectSlug(location); + const order: DocReviewState[] = ["awaiting_review", "approved", "changes_requested"]; + const idx = current ? order.indexOf(current as DocReviewState) : 0; + const next = order[(idx + 1) % order.length]!; + try { + await projectsApi.docReviews.set(pid, docPath, next); + setReviewStates((prev) => { + const nextMap: Record = { ...prev, [docPath]: next }; + return nextMap; + }); + } catch { + /* write rejected (forbidden / offline): keep the current stamp */ + } + }, + [location], + ); + // Workspace locations (user + per-agent + project) allow mutations and the stats // endpoint. Shared folders and the recycle bin are read-only here. const isWritable = location === "workspace" || isAgentLocation(location) || isProjectLocation(location); @@ -611,6 +706,11 @@ export function FilesApp({ .catch(() => setStats(null)); }, [location]); + // Refresh doc-review badges whenever we switch project locations. + useEffect(() => { + fetchReviewStates(location); + }, [location, fetchReviewStates]); + /* ---- Recycle bin ---- */ const fetchRecycle = useCallback(async () => { setRecycleLoading(true); @@ -722,9 +822,10 @@ export function FilesApp({ await apiFetch("/api/workspace/trash", { method: "DELETE" }); fetchWorkspaceTrash(); } catch (e: unknown) { - setWorkspaceTrashError(e instanceof Error ? e.message : "Empty trash failed"); + const msg = e instanceof Error ? e.message : "Empty trash failed"; + setError(msg); } - }, [fetchWorkspaceTrash, workspaceTrashItems.length]); + }, [workspaceTrashItems.length, fetchWorkspaceTrash]); /* ---- Actions ---- */ const handleNewFolder = useCallback(async () => { @@ -1602,6 +1703,20 @@ export function FilesApp({ {f.name} + {!f.is_dir && isProjectLocation(location) && ( + + cycleDocReview( + f.path || f.name, + reviewStates[f.path || f.name] ?? null, + ) + : undefined + } + /> + )} {!f.is_dir && ( {formatSize(f.size)} )} @@ -1656,6 +1771,7 @@ export function FilesApp({ Name Size Modified + Review Actions @@ -1677,6 +1793,16 @@ export function FilesApp({ setMenu({ x: e.clientX, y: e.clientY, file }); }} isCut={clipboard?.mode === "cut" && clipboard.location === location && clipboard.path === f.path} + reviewState={ + !f.is_dir && isProjectLocation(location) + ? reviewStates[f.path || f.name] ?? null + : null + } + onDocReviewClick={ + !f.is_dir && isProjectLocation(location) + ? () => cycleDocReview(f.path || f.name, reviewStates[f.path || f.name] ?? null) + : undefined + } /> ))} diff --git a/desktop/src/lib/projects.ts b/desktop/src/lib/projects.ts index 6ff385133..28a1c1dbd 100644 --- a/desktop/src/lib/projects.ts +++ b/desktop/src/lib/projects.ts @@ -120,6 +120,27 @@ export type Routine = { updated_at: number; }; +export type DocReviewState = "awaiting_review" | "approved" | "changes_requested"; + +export type DocReview = { + id: string; + project_id: string; + doc_path: string; + review_state: DocReviewState; + reviewed_by: string | null; + reviewed_at: number | null; + changes_requested_by: string | null; + changes_requested_at: number | null; + created_at: number; + updated_at: number; +}; + +export type DocReviewMissing = { + project_id: string; + doc_path: string; + review_state: null; +}; + export type TaskContextAncestor = { id: string; title: string; status: string }; export type TaskContextBlocker = { id: string; title: string; status: string }; @@ -326,6 +347,22 @@ export const projectsApi = { activity: (pid: string) => http<{ items: ProjectActivity[] }>(`/api/projects/${pid}/activity`).then((r) => r.items), + docReviews: { + list: (pid: string, state?: string) => + http<{ items: DocReview[] }>( + `/api/projects/${encodeURIComponent(pid)}/doc-reviews${state ? `?state=${encodeURIComponent(state)}` : ""}`, + ).then((r) => r.items), + get: (pid: string, docPath: string) => + http( + `/api/projects/${encodeURIComponent(pid)}/doc-review/${encodeURIComponent(docPath)}`, + ), + set: (pid: string, docPath: string, state: string) => + http( + `/api/projects/${encodeURIComponent(pid)}/doc-review/${encodeURIComponent(docPath)}`, + { method: "PUT", body: JSON.stringify({ state }) }, + ), + }, + subscribeEvents(projectId: string, onEvent: (ev: ProjectEvent) => void): () => void { const es = new EventSource(`/api/projects/${projectId}/events`); es.onmessage = (e) => { @@ -333,4 +370,20 @@ export const projectsApi = { }; return () => es.close(); }, + + docReview: { + get: (pid: string, docPath: string) => + http( + `/api/projects/${pid}/doc-review/${encodeURIComponent(docPath)}`, + ), + set: (pid: string, docPath: string, state: DocReviewState) => + http(`/api/projects/${pid}/doc-review/${encodeURIComponent(docPath)}`, { + method: "PUT", + body: JSON.stringify({ state }), + }), + list: (pid: string, state?: string) => + http<{ items: DocReview[] }>( + `/api/projects/${pid}/doc-reviews${state ? `?state=${state}` : ""}`, + ).then((r) => r.items), + }, }; diff --git a/tests/test_doc_review.py b/tests/test_doc_review.py new file mode 100644 index 000000000..20707bcc3 --- /dev/null +++ b/tests/test_doc_review.py @@ -0,0 +1,358 @@ +"""Doc-review stamp store: state machine, actor recording, invalid-transition +rejects, and project-scoped agent-token gating on writes. + +The store (tinyagentos/projects/doc_review_store.py) is exercised directly for +the review_state machine and actor recording. The routes +(tinyagentos/routes/project_doc_review.py) are exercised over HTTP to pin the +three security invariants introduced by the dual-auth gate: + + 1. A session owner (admin) can read and write any doc's review state. + 2. An approved agent token (scope project_doc_review) bound to project A can + drive ONLY project A's doc-review routes; a token bound elsewhere is + collapsed into an existence-hiding 404 (indistinguishable from a + non-owner session). + 3. A token missing the project_doc_review scope is rejected 403; an + unauthenticated (no session, no token) request is 401. + +Invalid review_state transitions raise ValueError in the store and map to HTTP +409 at the route; an unknown state maps to 400. +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from tinyagentos.agent_registry_store import mint_registry_token +from tinyagentos.projects.doc_review_store import ( + VALID_TRANSITIONS, + DocReviewStore, +) + + +# --------------------------------------------------------------------------- +# Store-level: review_state machine + actor recording +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_missing_returns_none(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + assert await store.get_review("p1", "a.md") is None + + +@pytest.mark.asyncio +async def test_awaiting_to_approved_records_actor(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + first = await store.set_review_state("p1", "a.md", "awaiting_review", "user-1") + assert first["review_state"] == "awaiting_review" + # First write to awaiting_review does not stamp an approver. + assert first["reviewed_by"] is None + assert first["changes_requested_by"] is None + + approved = await store.set_review_state("p1", "a.md", "approved", "agent-7") + assert approved["review_state"] == "approved" + assert approved["reviewed_by"] == "agent-7" + assert approved["reviewed_at"] is not None + assert approved["changes_requested_by"] is None + + +@pytest.mark.asyncio +async def test_awaiting_to_changes_requested_records_actor(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + await store.set_review_state("p1", "b.md", "awaiting_review", "user-1") + r = await store.set_review_state("p1", "b.md", "changes_requested", "agent-3") + assert r["review_state"] == "changes_requested" + assert r["changes_requested_by"] == "agent-3" + assert r["changes_requested_at"] is not None + assert r["reviewed_by"] is None + + +@pytest.mark.asyncio +async def test_approved_back_to_awaiting_keeps_actor(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + await store.set_review_state("p1", "c.md", "awaiting_review", "u") + await store.set_review_state("p1", "c.md", "approved", "a1") + r = await store.set_review_state("p1", "c.md", "awaiting_review", "u") + assert r["review_state"] == "awaiting_review" + # Returning to awaiting_review does not clear the stamped approver. + assert r["reviewed_by"] == "a1" + assert r["reviewed_at"] is not None + + +@pytest.mark.asyncio +async def test_changes_requested_back_to_awaiting_keeps_actor(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + await store.set_review_state("p1", "c.md", "awaiting_review", "u") + await store.set_review_state("p1", "c.md", "changes_requested", "a2") + r = await store.set_review_state("p1", "c.md", "awaiting_review", "u") + assert r["review_state"] == "awaiting_review" + assert r["changes_requested_by"] == "a2" + assert r["changes_requested_at"] is not None + + +@pytest.mark.asyncio +async def test_invalid_transition_raises_value_error(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + await store.set_review_state("p1", "d.md", "awaiting_review", "u") + await store.set_review_state("p1", "d.md", "approved", "a1") + # approved -> changes_requested is not a permitted transition. + with pytest.raises(ValueError) as exc: + await store.set_review_state("p1", "d.md", "changes_requested", "a2") + assert "invalid transition" in str(exc.value) + + # changes_requested -> approved is likewise invalid. + store2 = DocReviewStore(tmp_path / "projects2.db") + await store2.init() + await store2.set_review_state("p1", "e.md", "awaiting_review", "u") + await store2.set_review_state("p1", "e.md", "changes_requested", "a2") + with pytest.raises(ValueError) as exc2: + await store2.set_review_state("p1", "e.md", "approved", "a1") + assert "invalid transition" in str(exc2.value) + + +@pytest.mark.asyncio +async def test_unknown_state_raises_value_error(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + with pytest.raises(ValueError): + await store.set_review_state("p1", "f.md", "banana", "u") + + +@pytest.mark.asyncio +async def test_first_write_can_be_approved_or_changes_requested(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + # A brand-new doc may be stamped approved (or changes_requested) directly. + approved = await store.set_review_state("p1", "g.md", "approved", "a1") + assert approved["review_state"] == "approved" + assert approved["reviewed_by"] == "a1" + cr = await store.set_review_state("p1", "h.md", "changes_requested", "a2") + assert cr["review_state"] == "changes_requested" + assert cr["changes_requested_by"] == "a2" + + +@pytest.mark.asyncio +async def test_list_reviews_filters_by_state(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + await store.set_review_state("p1", "x.md", "awaiting_review", "u") + await store.set_review_state("p1", "y.md", "approved", "a1") + await store.set_review_state("p1", "z.md", "awaiting_review", "u") + + approved = await store.list_reviews("p1", state="approved") + assert [r["doc_path"] for r in approved] == ["y.md"] + + all_reviews = await store.list_reviews("p1") + assert len(all_reviews) == 3 + assert {r["doc_path"] for r in all_reviews} == {"x.md", "y.md", "z.md"} + + +@pytest.mark.asyncio +async def test_delete_review(tmp_path): + store = DocReviewStore(tmp_path / "projects.db") + await store.init() + await store.set_review_state("p1", "k.md", "awaiting_review", "u") + assert await store.delete_review("p1", "k.md") is True + assert await store.get_review("p1", "k.md") is None + assert await store.delete_review("p1", "missing.md") is False + + +# --------------------------------------------------------------------------- +# Route-level: project-scoped agent-token gating on writes +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def ctx(client): + """Reuse the session-admin `client` app and additionally init the + doc-review store and the agent registry + grants stores so agent tokens + can be minted against real stores.""" + app = client._transport.app + for attr in ("agent_registry", "agent_grants", "doc_review_store"): + store = getattr(app.state, attr) + if store._db is None: + await store.init() + uid = app.state.auth.find_user("admin")["id"] + yield SimpleNamespace(client=client, app=app, uid=uid) + for attr in ("agent_registry", "agent_grants", "doc_review_store"): + store = getattr(app.state, attr) + if store._db is not None: + await store.close() + + +def _bare(app): + """Cookieless client so requests carry only the Bearer header.""" + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + + +async def _new_project(ctx, slug): + resp = await ctx.client.post( + "/api/projects", json={"name": slug, "slug": slug} + ) + assert resp.status_code == 200, resp.text + return resp.json()["id"] + + +async def _mint_agent(ctx, project_id, scopes=("project_doc_review",)): + registry = ctx.app.state.agent_registry + grants = ctx.app.state.agent_grants + priv, _pub = ctx.app.state.agent_registry_keypair + rec = await registry.register( + framework="grok", + display_name="Grok", + origin="external-selfjoin", + handle="@grok", + ) + cid = rec["canonical_id"] + await registry.set_status(cid, "active") + for scope in scopes: + await grants.add_grant(cid, scope, project_id=project_id) + token = mint_registry_token( + cid, priv, user_id="u", framework="grok", project_id=project_id + ) + return cid, token + + +def _hdr(token): + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +class TestOwnerSession: + async def test_owner_can_write_and_read(self, ctx): + pid = await _new_project(ctx, "alpha") + resp = await ctx.client.put( + f"/api/projects/{pid}/doc-review/README.md", + json={"state": "awaiting_review"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["review_state"] == "awaiting_review" + + got = await ctx.client.get(f"/api/projects/{pid}/doc-review/README.md") + assert got.status_code == 200 + assert got.json()["review_state"] == "awaiting_review" + + async def test_owner_list_reviews(self, ctx): + pid = await _new_project(ctx, "alpha") + await ctx.client.put( + f"/api/projects/{pid}/doc-review/a.md", json={"state": "approved"} + ) + await ctx.client.put( + f"/api/projects/{pid}/doc-review/b.md", json={"state": "awaiting_review"} + ) + listed = await ctx.client.get(f"/api/projects/{pid}/doc-reviews") + assert listed.status_code == 200 + items = listed.json()["items"] + assert {r["doc_path"] for r in items} == {"a.md", "b.md"} + + async def test_owner_invalid_transition_is_409(self, ctx): + pid = await _new_project(ctx, "alpha") + await ctx.client.put( + f"/api/projects/{pid}/doc-review/README.md", + json={"state": "awaiting_review"}, + ) + await ctx.client.put( + f"/api/projects/{pid}/doc-review/README.md", json={"state": "approved"} + ) + resp = await ctx.client.put( + f"/api/projects/{pid}/doc-review/README.md", + json={"state": "changes_requested"}, + ) + assert resp.status_code == 409, resp.text + + async def test_owner_unknown_state_is_400(self, ctx): + pid = await _new_project(ctx, "alpha") + resp = await ctx.client.put( + f"/api/projects/{pid}/doc-review/README.md", json={"state": "banana"} + ) + assert resp.status_code == 400, resp.text + + async def test_owner_missing_doc_is_null_state(self, ctx): + pid = await _new_project(ctx, "alpha") + resp = await ctx.client.get(f"/api/projects/{pid}/doc-review/ghost.md") + assert resp.status_code == 200 + assert resp.json()["review_state"] is None + + +@pytest.mark.asyncio +class TestAgentScopeGating: + async def test_agent_can_write_own_project(self, ctx): + pid = await _new_project(ctx, "alpha") + _cid, token = await _mint_agent(ctx, pid) + async with _bare(ctx.app) as bare: + resp = await bare.put( + f"/api/projects/{pid}/doc-review/README.md", + json={"state": "awaiting_review"}, + headers=_hdr(token), + ) + assert resp.status_code == 200, resp.text + assert resp.json()["review_state"] == "awaiting_review" + + async def test_agent_write_to_own_project_records_actor(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, token = await _mint_agent(ctx, pid) + async with _bare(ctx.app) as bare: + resp = await bare.put( + f"/api/projects/{pid}/doc-review/README.md", + json={"state": "approved"}, + headers=_hdr(token), + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["reviewed_by"] == cid + assert body["review_state"] == "approved" + + async def test_agent_can_read_own_project(self, ctx): + pid = await _new_project(ctx, "alpha") + await ctx.client.put( + f"/api/projects/{pid}/doc-review/README.md", json={"state": "approved"} + ) + _cid, token = await _mint_agent(ctx, pid) + async with _bare(ctx.app) as bare: + resp = await bare.get( + f"/api/projects/{pid}/doc-review/README.md", headers=_hdr(token) + ) + assert resp.status_code == 200 + assert resp.json()["review_state"] == "approved" + + async def test_agent_other_project_is_404(self, ctx): + pid_a = await _new_project(ctx, "alpha") + pid_b = await _new_project(ctx, "bravo") + _cid, token_a = await _mint_agent(ctx, pid_a) + async with _bare(ctx.app) as bare: + resp = await bare.put( + f"/api/projects/{pid_b}/doc-review/README.md", + json={"state": "awaiting_review"}, + headers=_hdr(token_a), + ) + # Existence-hiding 404, identical to a non-owner session. + assert resp.status_code == 404, resp.text + + async def test_agent_missing_scope_is_403(self, ctx): + pid = await _new_project(ctx, "alpha") + _cid, token = await _mint_agent(ctx, pid, scopes=("a2a_receive",)) + async with _bare(ctx.app) as bare: + resp = await bare.put( + f"/api/projects/{pid}/doc-review/README.md", + json={"state": "awaiting_review"}, + headers=_hdr(token), + ) + assert resp.status_code == 403, resp.text + + async def test_unauthenticated_is_401(self, ctx): + pid = await _new_project(ctx, "alpha") + async with _bare(ctx.app) as bare: + resp = await bare.put( + f"/api/projects/{pid}/doc-review/README.md", + json={"state": "awaiting_review"}, + ) + assert resp.status_code == 401, resp.text diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 68d76e7d6..9e6a9e41d 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -370,6 +370,7 @@ async def _probe_backend(backend: dict) -> dict: from tinyagentos.projects.events import ProjectEventBroker from tinyagentos.projects.canvas.store import ProjectCanvasStore as ProjectCanvasStoreImpl from tinyagentos.projects.canvas.snapshotter import CanvasSnapshotter + from tinyagentos.projects.doc_review_store import DocReviewStore project_store = ProjectStore(data_dir / "projects.db") project_event_broker = ProjectEventBroker() from tinyagentos.desktop_control import DesktopCommandBroker @@ -388,6 +389,7 @@ async def _probe_backend(backend: dict) -> dict: from tinyagentos.projects.routines_store import RoutineStore routine_store = RoutineStore(data_dir / "routines.db") project_canvas_store = ProjectCanvasStoreImpl(data_dir / "projects.db", broker=project_event_broker) + doc_review_store = DocReviewStore(data_dir / "projects.db") from tinyagentos.decisions.decision_store import DecisionStore decision_store = DecisionStore(data_dir / "decisions.db") from tinyagentos.governance.policy_store import ExecutionPolicyStore @@ -517,6 +519,7 @@ async def lifespan(app: FastAPI): await routine_store.init() app.state.routine_store = routine_store await project_canvas_store.init() + await doc_review_store.init() await decision_store.init() app.state.decision_store = decision_store await execution_policy_store.init() @@ -834,6 +837,7 @@ async def _browser_reap_loop(app: FastAPI) -> None: app.state.project_event_broker = project_event_broker app.state.desktop_command_broker = desktop_command_broker app.state.project_canvas_store = project_canvas_store + app.state.doc_review_store = doc_review_store app.state.decision_store = decision_store app.state.shared_docs_store = shared_docs_store app.state.coding_session_store = coding_session_store @@ -1415,6 +1419,7 @@ async def _web_push_sender(row: dict) -> None: except Exception: logger.exception("canvas snapshotter stop failed") await project_canvas_store.close() + await doc_review_store.close() await project_task_store.close() await project_element_store.close() await routine_store.close() @@ -1572,6 +1577,7 @@ async def dispatch(self, request, call_next): app.state.project_event_broker = project_event_broker app.state.desktop_command_broker = desktop_command_broker app.state.project_canvas_store = project_canvas_store + app.state.doc_review_store = doc_review_store app.state.decision_store = decision_store app.state.execution_policies = execution_policy_store app.state.shared_docs_store = shared_docs_store diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py index 1a0e817ab..d026677c1 100644 --- a/tinyagentos/auth_middleware.py +++ b/tinyagentos/auth_middleware.py @@ -63,6 +63,26 @@ def _is_agent_task_path(method: str, path: str) -> bool: """True only for the exact subset of task routes a project_tasks token may reach. Strict method + anchored-regex match; everything else is excluded.""" return any(m == method and rx.match(path) for m, rx in _AGENT_TASK_ROUTES) + + +# Project doc-review stamp store routes an agent may reach with its own registry +# JWT (scope project_doc_review, verified + project-bound by the route). These +# are DYNAMIC paths (/api/projects/{pid}/doc-review/...), so a (method, +# compiled-regex) allowlist is used. The single-doc path is `path`-typed: +# doc_path may contain slashes (e.g. src/foo.md), so the final segment is `(.+)` +# rather than the slash-free `[^/]+` used for the task routes. Same contract as +# the task allowlist: the token only reaches the handler, which then verifies +# the JWT + grant + project binding; nothing else is reachable by the token. +_AGENT_DOC_REVIEW_ROUTES = ( + ("GET", re.compile(rf"^/api/projects/{_SEG}/doc-reviews$")), + ("GET", re.compile(rf"^/api/projects/{_SEG}/doc-review/(.+)$")), + ("PUT", re.compile(rf"^/api/projects/{_SEG}/doc-review/(.+)$")), +) + + +def _is_agent_doc_review_path(method: str, path: str) -> bool: + """True only for the doc-review routes a project_doc_review token may reach.""" + return any(m == method and rx.match(path) for m, rx in _AGENT_DOC_REVIEW_ROUTES) # Bundle assets and the SPA shell HTML must be reachable without auth so: # 1. The browser can install and cache the shell for offline / PWA use. # 2. After a backend restart the cached shell loads immediately without @@ -255,6 +275,7 @@ async def dispatch(self, request: Request, call_next): if ( path in _AGENT_TOKEN_PATHS or _is_agent_task_path(request.method, path) + or _is_agent_doc_review_path(request.method, path) ) and auth_header.lower().startswith("bearer "): request.state.user_id = None request.state.is_admin = False diff --git a/tinyagentos/projects/doc_review_store.py b/tinyagentos/projects/doc_review_store.py new file mode 100644 index 000000000..46c5ba551 --- /dev/null +++ b/tinyagentos/projects/doc_review_store.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import time + +from tinyagentos.base_store import BaseStore +from tinyagentos.projects.ids import new_id + +DOC_REVIEW_SCHEMA = """ +CREATE TABLE IF NOT EXISTS doc_reviews ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + doc_path TEXT NOT NULL, + review_state TEXT NOT NULL DEFAULT 'awaiting_review', + reviewed_by TEXT, + reviewed_at REAL, + changes_requested_by TEXT, + changes_requested_at REAL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_reviews_project_path + ON doc_reviews(project_id, doc_path); +CREATE INDEX IF NOT EXISTS idx_doc_reviews_state + ON doc_reviews(project_id, review_state); +""" + +VALID_TRANSITIONS: dict[str, list[str]] = { + "awaiting_review": ["approved", "changes_requested"], + "changes_requested": ["awaiting_review"], + "approved": ["awaiting_review"], +} + + +class DocReviewStore(BaseStore): + SCHEMA = DOC_REVIEW_SCHEMA + + def _row_to_review(self, row, description) -> dict: + keys = [d[0] for d in description] + return dict(zip(keys, row)) + + async def get_review(self, project_id: str, doc_path: str) -> dict | None: + async with self._db.execute( + "SELECT * FROM doc_reviews WHERE project_id = ? AND doc_path = ?", + (project_id, doc_path), + ) as cur: + row = await cur.fetchone() + if row is None: + return None + return self._row_to_review(row, cur.description) + + async def set_review_state( + self, + project_id: str, + doc_path: str, + new_state: str, + actor_id: str, + ) -> dict: + if new_state not in VALID_TRANSITIONS: + raise ValueError(f"invalid review state: {new_state}") + + now = time.time() + existing = await self.get_review(project_id, doc_path) + + if existing is None: + if new_state != "awaiting_review" and new_state not in VALID_TRANSITIONS.get("awaiting_review", []): + raise ValueError( + f"invalid transition: (new) -> {new_state}; " + f"first state must be awaiting_review or a direct transition target" + ) + review_id = new_id("rev") + reviewed_by = None + reviewed_at = None + changes_requested_by = None + changes_requested_at = None + if new_state == "approved": + reviewed_by = actor_id + reviewed_at = now + elif new_state == "changes_requested": + changes_requested_by = actor_id + changes_requested_at = now + await self._db.execute( + """INSERT INTO doc_reviews + (id, project_id, doc_path, review_state, + reviewed_by, reviewed_at, + changes_requested_by, changes_requested_at, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + review_id, project_id, doc_path, new_state, + reviewed_by, reviewed_at, + changes_requested_by, changes_requested_at, + now, now, + ), + ) + await self._db.commit() + return await self.get_review(project_id, doc_path) + + current_state = existing["review_state"] + allowed = VALID_TRANSITIONS.get(current_state, []) + if new_state not in allowed: + raise ValueError( + f"invalid transition: {current_state} -> {new_state}" + ) + + sets: list[str] = ["review_state = ?", "updated_at = ?"] + params: list = [new_state, now] + + if new_state == "approved": + sets.append("reviewed_by = ?") + sets.append("reviewed_at = ?") + params.extend([actor_id, now]) + elif new_state == "changes_requested": + sets.append("changes_requested_by = ?") + sets.append("changes_requested_at = ?") + params.extend([actor_id, now]) + + params.extend([project_id, doc_path]) + await self._db.execute( + f"UPDATE doc_reviews SET {', '.join(sets)} WHERE project_id = ? AND doc_path = ?", + params, + ) + await self._db.commit() + return await self.get_review(project_id, doc_path) + + async def list_reviews( + self, project_id: str, *, state: str | None = None + ) -> list[dict]: + if state is not None: + async with self._db.execute( + "SELECT * FROM doc_reviews WHERE project_id = ? AND review_state = ? ORDER BY doc_path", + (project_id, state), + ) as cur: + rows = await cur.fetchall() + else: + async with self._db.execute( + "SELECT * FROM doc_reviews WHERE project_id = ? ORDER BY doc_path", + (project_id,), + ) as cur: + rows = await cur.fetchall() + return [self._row_to_review(r, cur.description) for r in rows] + + async def delete_review(self, project_id: str, doc_path: str) -> bool: + async with self._db.execute( + "DELETE FROM doc_reviews WHERE project_id = ? AND doc_path = ?", + (project_id, doc_path), + ) as cur: + deleted = cur.rowcount > 0 + await self._db.commit() + return deleted diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py index 2322ff856..7480ef5fc 100644 --- a/tinyagentos/routes/__init__.py +++ b/tinyagentos/routes/__init__.py @@ -55,6 +55,9 @@ def register_all_routers(app): from tinyagentos.routes import projects as projects_routes app.include_router(projects_routes.router) + from tinyagentos.routes.project_doc_review import router as project_doc_review_router + app.include_router(project_doc_review_router) + from tinyagentos.routes import routines as routines_routes app.include_router(routines_routes.router) @@ -160,6 +163,9 @@ def register_all_routers(app): from tinyagentos.routes.project_canvas import router as project_canvas_router app.include_router(project_canvas_router) + from tinyagentos.routes.project_doc_review import router as project_doc_review_router + app.include_router(project_doc_review_router) + from tinyagentos.routes.desktop_control import router as desktop_control_router app.include_router(desktop_control_router) diff --git a/tinyagentos/routes/project_doc_review.py b/tinyagentos/routes/project_doc_review.py new file mode 100644 index 000000000..1c6964a09 --- /dev/null +++ b/tinyagentos/routes/project_doc_review.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from tinyagentos.agent_token_auth import ( + PROJECT_SCOPE_MISMATCH_DETAIL, + check_agent_scope_for_project, +) +from tinyagentos.auth_context import CurrentUser +from tinyagentos.routes.projects import _get_owned_project + +router = APIRouter() + + +class DocReviewUpdate(BaseModel): + state: str = Field(..., description="Target review state") + + +async def _authorize_doc_review_actor( + request: Request, pstore, project_id: str +) -> "tuple[str, bool, dict] | JSONResponse": + uid = getattr(request.state, "user_id", None) + if uid: + user = CurrentUser( + user_id=uid, + is_admin=bool(getattr(request.state, "is_admin", False)), + ) + project_or_err = await _get_owned_project(pstore, project_id, user) + if isinstance(project_or_err, JSONResponse): + return project_or_err + return (user.user_id, False, project_or_err) + + try: + caller = await check_agent_scope_for_project( + request, "project_doc_review", project_id + ) + except HTTPException as exc: + if exc.status_code == 403 and exc.detail == PROJECT_SCOPE_MISMATCH_DETAIL: + return JSONResponse({"error": "not found"}, status_code=404) + raise + if caller is None: + return JSONResponse({"error": "not found"}, status_code=404) + project = await pstore.get_project(project_id) + if project is None: + return JSONResponse({"error": "not found"}, status_code=404) + return (caller, True, project) + + +@router.put("/api/projects/{project_id}/doc-review/{doc_path:path}") +async def update_doc_review( + project_id: str, + doc_path: str, + payload: DocReviewUpdate, + request: Request, +): + store = request.app.state.doc_review_store + pstore = request.app.state.project_store + + auth = await _authorize_doc_review_actor(request, pstore, project_id) + if isinstance(auth, JSONResponse): + return auth + actor_id, _is_agent, _project = auth + + try: + review = await store.set_review_state( + project_id, doc_path, payload.state, actor_id + ) + except ValueError as exc: + detail = str(exc) + if "invalid transition" in detail: + raise HTTPException(status_code=409, detail=detail) + raise HTTPException(status_code=400, detail=detail) + + return review + + +@router.get("/api/projects/{project_id}/doc-review/{doc_path:path}") +async def get_doc_review( + project_id: str, + doc_path: str, + request: Request, +): + store = request.app.state.doc_review_store + pstore = request.app.state.project_store + + auth = await _authorize_doc_review_actor(request, pstore, project_id) + if isinstance(auth, JSONResponse): + return auth + + review = await store.get_review(project_id, doc_path) + if review is None: + return {"project_id": project_id, "doc_path": doc_path, "review_state": None} + return review + + +@router.get("/api/projects/{project_id}/doc-reviews") +async def list_doc_reviews( + project_id: str, + request: Request, + state: str | None = None, +): + store = request.app.state.doc_review_store + pstore = request.app.state.project_store + + auth = await _authorize_doc_review_actor(request, pstore, project_id) + if isinstance(auth, JSONResponse): + return auth + + reviews = await store.list_reviews(project_id, state=state) + return {"items": reviews}