diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx index e50f21bb78..1771c5a851 100644 --- a/packages/dashboard/app/components/GitManagerModal.tsx +++ b/packages/dashboard/app/components/GitManagerModal.tsx @@ -267,6 +267,34 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const [workspaceRepos, setWorkspaceRepos] = useState([]); const [selectedRepo, setSelectedRepo] = useState(null); const gitRepoPath = selectedRepo ?? undefined; + /* + FNXC:Workspace 2026-06-25-00:10: + In a workspace the project root is a non-git browse-only directory. On modal open the section fetch + fires immediately with no repoPath (selectedRepo not yet resolved), so a git status against the root + returns "Not a git repository" and toasts a spurious error on every open — even though the repo + dropdown renders correctly. fetchWorkspaceRepos resolves a tick later and re-fetches against a real + sub-repo. We track detection status in a REF (read inside the async fetch catch without a stale + closure or extra render dep) so we can SUPPRESS that one benign root-race error: a "Not a git + repository" with no repoPath while detection is unresolved OR has detected a workspace. A genuine + broken non-workspace project (resolved, repos empty) still surfaces the error normally. + */ + const workspaceDetectionRef = useRef<{ resolved: boolean; isWorkspace: boolean }>({ resolved: false, isWorkspace: false }); + // Tracks whether the most recent fetch suppressed a root-race error, and a state tick that flips + // when detection resolves — together they let a genuinely-broken NON-workspace project re-surface + // the error (a single re-fetch) after detection settles, without adding a redundant fetch to the + // common non-workspace-OK path (where the first fetch already succeeded). + const suppressedRootRaceRef = useRef(false); + const [detectionResolved, setDetectionResolved] = useState(false); + /* + FNXC:Workspace 2026-06-25-09:40 (detection generation guard): + A rapid projectId switch (or close→reopen) can leave a previous project's fetchWorkspaceRepos + promise in flight. When it resolves it must NOT overwrite the CURRENT project's detection verdict — + doing so could suppress a real error for the new project or mis-fire the re-surface effect. Each + detection run is stamped with a monotonically increasing generation; only the latest run is allowed + to mutate detection state, and the effect cleanup bumps the generation so a superseded/unmounted run + is abandoned. + */ + const detectionGenerationRef = useRef(0); // ── Changes state const [fileChanges, setFileChanges] = useState([]); @@ -322,6 +350,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj if (!isOpen) return; setLoading(true); setSectionError(null); + suppressedRootRaceRef.current = false; try { switch (activeSection) { case "status": { @@ -375,8 +404,30 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj } } } catch (err) { - setSectionError(getErrorMessage(err) || t("git.failedToFetchData", "Failed to fetch git data")); - addToast(getErrorMessage(err) || t("git.failedToFetchData", "Failed to fetch git data"), "error"); + const message = getErrorMessage(err) || t("git.failedToFetchData", "Failed to fetch git data"); + /* + FNXC:Workspace 2026-06-25-00:10: + Suppress the benign workspace-root race: on open, the first fetch fires before selectedRepo + resolves (no repoPath → the non-git browse root), which fails "Not a git repository". A workspace + re-fetches against a real sub-repo a tick later. Only swallow this when there is NO repoPath AND + detection is still pending OR has confirmed a workspace; a resolved non-workspace project surfaces + a genuine "Not a git repository" normally. + */ + const detection = workspaceDetectionRef.current; + const isWorkspaceRootRace = + gitRepoPath === undefined && + /not a git repository/i.test(message) && + (!detection.resolved || detection.isWorkspace); + if (isWorkspaceRootRace) { + // Benign: defer reporting. A workspace re-fetches against its sub-repo (selectedRepo change); + // a non-workspace re-fetches once via the detection-resolved effect below, surfacing any real + // error then. + suppressedRootRaceRef.current = true; + setSectionError(null); + } else { + setSectionError(message); + addToast(message, "error"); + } } finally { setLoading(false); } @@ -909,20 +960,46 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj selectedRepo in the effect deps, preserving the projectId-keyed intent. */ useEffect(() => { + // Reset detection on project switch so a stale verdict can't suppress a real error. The + // generation guard (see ref note above) makes a superseded in-flight resolution a no-op. + const gen = ++detectionGenerationRef.current; + workspaceDetectionRef.current = { resolved: false, isWorkspace: false }; + suppressedRootRaceRef.current = false; + setDetectionResolved(false); fetchWorkspaceRepos(projectId) .then((result) => { + if (gen !== detectionGenerationRef.current) return; const repos = result.repos; + workspaceDetectionRef.current = { resolved: true, isWorkspace: repos.length > 0 }; setWorkspaceRepos(repos); setSelectedRepo((current) => current && repos.includes(current) ? current : (repos[0] ?? null), ); }) .catch(() => { + if (gen !== detectionGenerationRef.current) return; + workspaceDetectionRef.current = { resolved: true, isWorkspace: false }; setWorkspaceRepos([]); setSelectedRepo(null); + }) + .finally(() => { + if (gen !== detectionGenerationRef.current) return; + setDetectionResolved(true); }); + // Bump the generation on cleanup so an unmounted/superseded run's late resolution is abandoned. + return () => { detectionGenerationRef.current++; }; }, [projectId]); // keyed on projectId; selectedRepo is revalidated via the functional updater + // FNXC:Workspace 2026-06-25-00:10: once detection settles, re-surface a suppressed root-race error + // for a NON-workspace project (a genuinely broken/non-git repo). A workspace already re-fetches via + // the selectedRepo change, so we skip it here to avoid a redundant second fetch. + useEffect(() => { + if (isOpen && detectionResolved && suppressedRootRaceRef.current && !workspaceDetectionRef.current.isWorkspace) { + suppressedRootRaceRef.current = false; + void fetchSectionData(); + } + }, [isOpen, detectionResolved, fetchSectionData]); + const handleSyncIntegrationTip = useCallback(async () => { if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return; const worktreePath = rootDir; diff --git a/packages/dashboard/app/components/TaskChangesTab.tsx b/packages/dashboard/app/components/TaskChangesTab.tsx index 5b220116eb..582d3d67fb 100644 --- a/packages/dashboard/app/components/TaskChangesTab.tsx +++ b/packages/dashboard/app/components/TaskChangesTab.tsx @@ -19,6 +19,15 @@ interface TaskChangesTabProps { projectId?: string; column?: ColumnId; mergeDetails?: MergeDetails; + /** + * FNXC:Workspace 2026-06-25-09:40: + * True for a workspace (multi-repo) task. Such a task has no singular + * `worktree`/`branch` — its changes live in per-sub-repo worktrees, which the + * backend `/tasks/:id/diff` now aggregates (repo-prefixed paths). Used to skip + * the single-repo "No worktree available" empty state, which would otherwise + * fire on every workspace task because `worktree` is undefined. + */ + isWorkspace?: boolean; /** * Files modified by the task during execution, captured from the worktree. * Used as a last-resort fallback when the live worktree diff is empty or the @@ -127,7 +136,7 @@ interface NormalizedFile { * modifiedFiles view instead of showing a hard error. This preserves the prior * graceful behavior while allowing FN-4563/FN-4576 lineage-backed parity. */ -export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails, modifiedFiles }: TaskChangesTabProps) { +export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails, modifiedFiles, isWorkspace }: TaskChangesTabProps) { const { t } = useTranslation("app"); const [files, setFiles] = useState([]); const [stats, setStats] = useState<{ filesChanged: number; additions: number; deletions: number }>({ filesChanged: 0, additions: 0, deletions: 0 }); @@ -309,7 +318,10 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai } // Non-done task without a worktree → only show fallback state when branch-fallback diff is empty. - if (!isDone && !worktree && files.length === 0) { + // A workspace task legitimately has no singular `worktree` (its changes come from the per-sub-repo + // aggregation), so it must NOT hit this "No worktree available" branch — fall through to the + // standard empty/populated rendering below. + if (!isDone && !worktree && !isWorkspace && files.length === 0) { if (modifiedFiles && modifiedFiles.length > 0) { return renderModifiedFilesFallback(modifiedFiles, false, undefined, "execution", t); } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 606724c8c9..66efd7109b 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3399,7 +3399,7 @@ export function TaskDetailContent({ )} ) : activeTab === "changes" ? ( - + ) : activeTab === "review" ? ( { fetchAheadCommits: vi.fn(), fetchRemoteCommits: vi.fn(), fetchBranchCommits: vi.fn(), + // FNXC:Test 2026-06-25-00:10: GitManagerModal detects workspace sub-repos on mount via + // fetchWorkspaceRepos; the mock was never added when that call landed, breaking the whole suite + // at import. Default to a non-workspace project ({ repos: [] }) so the root git path is exercised. + fetchWorkspaceRepos: vi.fn().mockResolvedValue({ repos: [] }), }; }); @@ -112,6 +116,7 @@ import { fetchAheadCommits, fetchRemoteCommits, fetchBranchCommits, + fetchWorkspaceRepos, } from "../../api"; import { subscribeSse } from "../../sse-bus"; @@ -284,6 +289,64 @@ describe("GitManagerModal", () => { (fetchRemoteCommits as any).mockResolvedValue([]); }); + // ── Workspace root-race toast suppression ─────────────────── + // FNXC:Workspace 2026-06-25-00:10: a workspace project's root is non-git, so the first git status + // (no repoPath yet) fails "Not a git repository". That benign race must NOT toast; a real + // non-workspace project with the same error must. + + it("does NOT toast 'Not a git repository' for a workspace project's initial root-race fetch", async () => { + (fetchWorkspaceRepos as any).mockResolvedValue({ repos: ["openvide", "swarmclaw"] }); + // Root (no repoPath) → not a git repo; a real sub-repo → resolves. + (fetchGitStatus as any).mockImplementation((_pid: unknown, _opts: unknown, repoPath?: string) => + repoPath + ? Promise.resolve({ branch: "main", commit: "abc1234", isDirty: false, ahead: 0, behind: 0 }) + : Promise.reject(new Error("Not a git repository")), + ); + + render(); + + // Wait until the re-fetch against the selected sub-repo has happened. + await waitFor(() => { + expect((fetchGitStatus as any).mock.calls.some((c: unknown[]) => c[2] === "openvide")).toBe(true); + }); + expect(mockAddToast).not.toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error"); + }); + + it("DOES toast 'Not a git repository' for a real non-workspace project", async () => { + (fetchWorkspaceRepos as any).mockResolvedValue({ repos: [] }); + (fetchGitStatus as any).mockRejectedValue(new Error("Not a git repository")); + + render(); + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error"); + }); + }); + + it("does not let a stale workspace project's late detection suppress a real error after a rapid project switch", async () => { + // FNXC:Workspace 2026-06-25-09:40 (generation guard): switch from workspace project A (whose + // fetchWorkspaceRepos resolves LATE) to broken non-workspace project B before A resolves. A's late + // "workspace" verdict must be abandoned (generation guard) so it can't suppress B's real error. + let resolveA: (v: { repos: string[] }) => void = () => {}; + const aPromise = new Promise<{ repos: string[] }>((r) => { resolveA = r; }); + (fetchWorkspaceRepos as any).mockImplementation((pid: string) => + pid === "projA" ? aPromise : Promise.resolve({ repos: [] })); + (fetchGitStatus as any).mockRejectedValue(new Error("Not a git repository")); + + const { rerender } = render( + , + ); + // Switch to B before A's detection resolves. + rerender(); + // A resolves late as a workspace — must be ignored for the now-current project B. + resolveA({ repos: ["openvide"] }); + + // B is a genuinely broken non-workspace repo → its error must still surface. + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error"); + }); + }); + // ── Basic Rendering ───────────────────────────────────────── it("renders nothing when not open", () => { diff --git a/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx index 38baf06f45..11e944261c 100644 --- a/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx @@ -201,6 +201,44 @@ describe("TaskChangesTab — worktree-backed (non-done tasks)", () => { }); }); +// FNXC:Workspace 2026-06-25-00:40: a workspace task has no singular `worktree` — its changes come +// from the backend's per-sub-repo aggregation (repo-prefixed paths). It must render those instead of +// the single-repo "No worktree available" empty state. +describe("TaskChangesTab — workspace tasks", () => { + it("renders aggregated repo-prefixed files for a workspace task (no singular worktree)", async () => { + mockFetchTaskDiff.mockResolvedValue({ + files: [ + { path: "openvide/src/a.ts", status: "added", additions: 2, deletions: 0, patch: "@@ -0,0 +1,2 @@\n+a\n+aa" }, + { path: "swarmclaw/lib/b.ts", status: "modified", additions: 1, deletions: 1, patch: "@@ -1 +1 @@\n+b\n-old" }, + ], + stats: { filesChanged: 2, additions: 3, deletions: 1 }, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("openvide/src/a.ts")).toBeTruthy(); + }); + expect(screen.getByText("swarmclaw/lib/b.ts")).toBeTruthy(); + expect(screen.queryByText("No worktree available for this task.")).toBeNull(); + }); + + it("does NOT show 'No worktree available' for an empty workspace task", async () => { + mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("No files modified.")).toBeTruthy(); + }); + expect(screen.queryByText("No worktree available for this task.")).toBeNull(); + }); +}); + describe("TaskChangesTab — commit-backed (done tasks)", () => { it("loads diff from fetchTaskDiff for done task with commitSha", async () => { mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF); diff --git a/packages/dashboard/src/__tests__/routes-diff-workspace.test.ts b/packages/dashboard/src/__tests__/routes-diff-workspace.test.ts new file mode 100644 index 0000000000..15ac0b266b --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-diff-workspace.test.ts @@ -0,0 +1,154 @@ +/* +FNXC:Workspace 2026-06-25-00:40: +A workspace (multi-repo) task has no singular `worktree`/`branch` — its changes live in per-sub-repo +worktrees recorded in `task.workspaceWorktrees`. `/tasks/:id/diff` and `/tasks/:id/file-diffs` must +aggregate each sub-repo's diff (computed in that sub-repo's worktree) and prefix every file path with +the sub-repo key, instead of diffing the non-git workspace root (which returns empty). + +We mock runGitCommand (keyed by cwd so each sub-repo returns its own files) and node:fs/promises +access (so the sub-repo worktrees "exist") — no real/slow git. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import type { Task } from "@fusion/core"; + +const runGitCommandMock = vi.fn<(...args: any[]) => Promise>(); + +vi.mock("../routes/resolve-diff-base.js", () => ({ + // Per-repo base: the route passes the sub-repo's captured baseCommitSha through. + resolveDiffBase: vi.fn(async (task: any) => task.baseCommitSha), + runGitCommand: (...args: any[]) => runGitCommandMock(...args), +})); + +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { ...actual, access: vi.fn(async () => undefined) }; +}); + +import { createServer } from "../server.js"; + +class MockStore extends EventEmitter { + private tasks = new Map(); + getRootDir(): string { return "/ws-root"; } + getFusionDir(): string { return "/ws-root/.fusion"; } + getDatabase() { + return { exec: vi.fn(), prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }) }; + } + getMissionStore() { + return { + listMissions: vi.fn().mockResolvedValue([]), createMission: vi.fn(), getMission: vi.fn(), updateMission: vi.fn(), deleteMission: vi.fn(), + listTemplates: vi.fn().mockResolvedValue([]), createTemplate: vi.fn(), getTemplate: vi.fn(), updateTemplate: vi.fn(), deleteTemplate: vi.fn(), instantiateMission: vi.fn(), + }; + } + async listTasks(): Promise { return Array.from(this.tasks.values()); } + getTask(id: string): Task | undefined { return this.tasks.get(id); } + addTask(task: Task): void { this.tasks.set(task.id, task); } + async getTaskCommitAssociationsByLineageId(): Promise<[]> { return []; } +} + +function workspaceTask(): Task { + return { + id: "MULT-002", title: "ws task", description: "", column: "in-review", + dependencies: [], steps: [], currentStep: 0, log: [], + createdAt: "2026-06-24T00:00:00.000Z", updatedAt: "2026-06-24T00:00:00.000Z", + worktree: undefined, branch: undefined, + workspaceWorktrees: { + // Intentionally non-alphabetical insertion to prove sorted, deterministic output. + swarmclaw: { worktreePath: "/wt/swarmclaw", branch: "fusion/mult-002", baseCommitSha: "baseS" }, + openvide: { worktreePath: "/wt/openvide", branch: "fusion/mult-002", baseCommitSha: "baseO" }, + }, + } as Task; +} + +// Per-cwd git responses. Anything not listed throws — restrictActiveCommittedFilesToOwnTask's +// attribution probes hit that and are swallowed (display-only), preserving the broad diff. +const RESPONSES: Record> = { + "/wt/openvide": { + "diff --name-status -M baseO..HEAD": "A\tsrc/a.ts", + "diff --cached --name-status -M": "", + "diff --name-status -M": "", + "diff baseO -- src/a.ts": "+a\n+aa\n", + }, + "/wt/swarmclaw": { + "diff --name-status -M baseS..HEAD": "M\tlib/b.ts", + "diff --cached --name-status -M": "", + "diff --name-status -M": "", + "diff baseS -- lib/b.ts": "+b\n-old\n", + }, +}; + +describe("workspace task diff aggregation", () => { + beforeEach(() => { + vi.clearAllMocks(); + runGitCommandMock.mockImplementation(async (gitArgs: string[], cwd?: string) => { + const repo = (cwd && RESPONSES[cwd]) || {}; + const key = gitArgs.join(" "); + if (key in repo) return repo[key] ?? ""; + throw new Error(`Unexpected git command [${cwd}]: ${key}`); + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("/diff aggregates per-sub-repo files with repo-prefixed paths and summed stats", async () => { + const store = new MockStore(); + store.addTask(workspaceTask()); + const app = createServer(store as any); + + const { get } = await import("../test-request.js"); + const res = await get(app, "/api/tasks/MULT-002/diff"); + + expect(res.status).toBe(200); + expect(res.body.files.map((f: any) => f.path)).toEqual(["openvide/src/a.ts", "swarmclaw/lib/b.ts"]); + expect(res.body.files.find((f: any) => f.path === "openvide/src/a.ts").status).toBe("added"); + expect(res.body.stats).toEqual({ filesChanged: 2, additions: 3, deletions: 1 }); + }); + + it("preserves deterministic repo-sorted order across the concurrent (parallelized) aggregation", async () => { + // FNXC:WorkspaceDiff 2026-06-25-09:40: sub-repos are now diffed concurrently; the output must + // still be sorted by repo key regardless of which sub-repo's git calls finish first. Three repos + // inserted out of order, with the first-sorted repo deliberately given the slowest git response. + const task = workspaceTask(); + (task as any).workspaceWorktrees = { + zulu: { worktreePath: "/wt/zulu", branch: "fusion/mult-002", baseCommitSha: "baseZ" }, + alpha: { worktreePath: "/wt/alpha", branch: "fusion/mult-002", baseCommitSha: "baseA" }, + mike: { worktreePath: "/wt/mike", branch: "fusion/mult-002", baseCommitSha: "baseM" }, + }; + const resp: Record> = { + "/wt/alpha": { "diff --name-status -M baseA..HEAD": "A\ta.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff baseA -- a.ts": "+x\n" }, + "/wt/mike": { "diff --name-status -M baseM..HEAD": "A\tm.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff baseM -- m.ts": "+y\n" }, + "/wt/zulu": { "diff --name-status -M baseZ..HEAD": "A\tz.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff baseZ -- z.ts": "+w\n" }, + }; + runGitCommandMock.mockImplementation(async (gitArgs: string[], cwd?: string) => { + const key = gitArgs.join(" "); + const repo = (cwd && resp[cwd]) || {}; + if (key in repo) { + // Make the first-sorted repo (alpha) resolve LAST to prove order is by key, not completion. + if (cwd === "/wt/alpha") await new Promise((r) => setTimeout(r, 5)); + return repo[key] ?? ""; + } + throw new Error(`Unexpected git command [${cwd}]: ${key}`); + }); + + const store = new MockStore(); + store.addTask(task); + const app = createServer(store as any); + const { get } = await import("../test-request.js"); + const res = await get(app, "/api/tasks/MULT-002/diff"); + + expect(res.status).toBe(200); + expect(res.body.files.map((f: any) => f.path)).toEqual(["alpha/a.ts", "mike/m.ts", "zulu/z.ts"]); + }); + + it("/file-diffs returns repo-prefixed per-file patches", async () => { + const store = new MockStore(); + store.addTask(workspaceTask()); + const app = createServer(store as any); + + const { get } = await import("../test-request.js"); + const res = await get(app, "/api/tasks/MULT-002/file-diffs"); + + expect(res.status).toBe(200); + expect(res.body.map((f: any) => f.path)).toEqual(["openvide/src/a.ts", "swarmclaw/lib/b.ts"]); + expect(res.body.find((f: any) => f.path === "swarmclaw/lib/b.ts").diff).toContain("-old"); + }); +}); diff --git a/packages/dashboard/src/routes/register-session-diff-routes.ts b/packages/dashboard/src/routes/register-session-diff-routes.ts index b41ec8d5ec..5ddc7785cc 100644 --- a/packages/dashboard/src/routes/register-session-diff-routes.ts +++ b/packages/dashboard/src/routes/register-session-diff-routes.ts @@ -1,6 +1,8 @@ import { access } from "node:fs/promises"; +import { join } from "node:path"; import type { Request, Router } from "express"; import type { RunAuditEvent, RunAuditEventFilter } from "@fusion/core"; +import { isWorkspaceTask } from "@fusion/core"; import { ApiError, notFound, rethrowAsApiError } from "../api-error.js"; import { resolveDiffBase, runGitCommand } from "./resolve-diff-base.js"; import { countPatchLines } from "./diff-counts.js"; @@ -372,6 +374,235 @@ async function collectDoneRangeFiles(range: string, rootDir: string): Promise(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const results = new Array(items.length); + let cursor = 0; + const workerCount = Math.max(1, Math.min(limit, items.length)); + const workers = Array.from({ length: workerCount }, async () => { + for (;;) { + const index = cursor++; + if (index >= items.length) return; + results[index] = await fn(items[index]!, index); + } + }); + await Promise.all(workers); + return results; +} + +/** + * Build the per-file detailed diff for a SINGLE worktree: committed + * (diffBase..HEAD) + staged + unstaged, with the committed set scoped to the + * task's own commits. Untracked files are intentionally excluded — at review + * time they are almost always build artifacts/cache/logs. + * + * Extracted so the single-repo diff endpoints AND the per-sub-repo workspace + * aggregation (computeWorkspaceTaskFiles) share ONE implementation. The + * single-repo `/tasks/:id/diff` and `/tasks/:id/file-diffs` paths must remain + * behaviour-identical to their previous inline form. + */ +async function computeWorktreeDetailedFiles( + taskLike: { id: string; baseBranch?: string; baseCommitSha?: string }, + cwd: string, + timeoutMs: number, +): Promise { + const diffBase = await resolveDiffBase(taskLike, cwd, "HEAD", undefined, { enableDisplayRecovery: true }); + + const fileMap = new Map(); + + if (diffBase) { + try { + const committedOutput = (await runGitCommand(["diff", "--name-status", "-M", `${diffBase}..HEAD`], cwd, timeoutMs)).trim(); + for (const line of committedOutput.split("\n").filter(Boolean)) { + const parsed = parseNameStatusLine(line); + if (!parsed) continue; + fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); + } + } catch { + // committed diff failed + } + } + + await restrictActiveCommittedFilesToOwnTask(fileMap, { + taskId: taskLike.id, + diffBase, + worktreePath: cwd, + runGit: (args) => runGitCommand(args, cwd, timeoutMs), + }); + + try { + const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, timeoutMs)).trim(); + for (const line of stagedOutput.split("\n").filter(Boolean)) { + const parsed = parseNameStatusLine(line); + if (!parsed || fileMap.has(parsed.path)) continue; + fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); + } + } catch { + // staged diff failed + } + + try { + const workingTreeOutput = (await runGitCommand(["diff", "--name-status", "-M"], cwd, timeoutMs)).trim(); + for (const line of workingTreeOutput.split("\n").filter(Boolean)) { + const parsed = parseNameStatusLine(line); + if (!parsed || fileMap.has(parsed.path)) continue; + fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); + } + } catch { + // working tree diff failed + } + + /* + FNXC:WorkspaceDiff 2026-06-25-09:40: + The per-file `git diff` patch fetch is the dominant cost (one subprocess per changed file). Run it + with bounded concurrency instead of a serial await loop — independent files do not depend on each + other, so this collapses M serial git spawns to ~M/limit wall-clock. We deliberately do NOT skip the + patch for deleted files: /file-diffs filters out empty-patch entries and the patch supplies the + additions/deletions counts, so a delete needs its real patch to stay visible and counted. Status + uses the shared parseStatusCode helper (single source of truth for the A/D/R/M mapping). + */ + const entries = Array.from(fileMap.entries()).filter(([filePath]) => Boolean(filePath)); + const results = await mapWithConcurrency(entries, 8, async ([filePath, { statusCode, oldPath }]) => { + const status = parseStatusCode(statusCode); + + let patch = ""; + try { + patch = diffBase + ? await runGitCommand(["diff", diffBase, "--", filePath], cwd, timeoutMs) + : await runGitCommand(["diff", "HEAD", "--", filePath], cwd, timeoutMs); + } catch { + // ignore individual file errors + } + + const { additions, deletions } = countPatchLines(patch); + return oldPath + ? { path: filePath, status, additions, deletions, patch, oldPath } + : { path: filePath, status, additions, deletions, patch }; + }); + + return results; +} + +/** + * Aggregate a workspace task's changed files across ALL acquired sub-repo + * worktrees. A workspace task has no singular `task.worktree`/`task.branch` + * (those are null by design); its per-repo state lives in + * `task.workspaceWorktrees`. Each sub-repo's diff is computed in its own live + * worktree (in-progress/in-review) or, when that worktree is gone (done tasks), + * from its landed range in the sub-repo root. Every file path is prefixed with + * the sub-repo key (e.g. `openvide/src/foo.ts`) so the Changes tab shows which + * sub-repo each file belongs to. A missing/unreadable sub-repo is skipped + * best-effort rather than failing the whole response. + */ +async function computeWorkspaceTaskFiles( + task: { + id: string; + baseBranch?: string; + workspaceWorktrees?: Record; + }, + rootDir: string, + timeoutMs: number, +): Promise { + const worktrees = task.workspaceWorktrees ?? {}; + + /* + FNXC:WorkspaceDiff 2026-06-25-09:40: + Resolve each sub-repo's diff CONCURRENTLY (bounded) rather than awaiting them one at a time: every + sub-repo's git work is independent, so a serial loop made the aggregate cost N×(per-repo) and could + block the response for a long time on a many-repo task. Keys are sorted first and mapped by position, + so the aggregated output stays in deterministic repo-sorted order regardless of completion order. + */ + const repoRels = Object.keys(worktrees).sort(); + const perRepo = await mapWithConcurrency(repoRels, 4, async (repoRel) => { + const entry = worktrees[repoRel]; + if (!entry) return [] as WorktreeDetailedFile[]; + + let repoFiles: WorktreeDetailedFile[] = []; + + // Prefer the live sub-repo worktree (in-progress / in-review). The access() + // probe is an optimistic fast-path skip; the try/catch below is the real guard. + let worktreeUsable = false; + if (entry.worktreePath) { + try { + await access(entry.worktreePath); + worktreeUsable = true; + } catch { + // worktree gone → fall through to the landed-range fallback + } + } + if (worktreeUsable) { + try { + repoFiles = await computeWorktreeDetailedFiles( + // Per-repo base: use the sub-repo's own captured fork point, with the + // workspace task's baseBranch stripped so resolveDiffBase uses the + // per-repo baseCommitSha rather than a shared workspace branch. + { id: task.id, baseBranch: undefined, baseCommitSha: entry.baseCommitSha }, + entry.worktreePath, + timeoutMs, + ); + } catch { + repoFiles = []; + } + } + + // Fallback: landed range in the sub-repo root (a done task whose per-repo + // worktree was already cleaned up). Each sub-repo lands independently with + // its own baseCommitSha → landedSha. + // FNXC:WorkspaceDiff 2026-06-25-09:40: collectDoneRangeFiles returns AggregatedDoneTaskFile, which + // carries no oldPath, so a renamed file's rename-SOURCE is unavailable on this done fallback (the + // file still shows under its new path). The live-worktree path above does preserve oldPath. + if (repoFiles.length === 0 && entry.baseCommitSha && entry.landedSha) { + const repoRootDir = join(rootDir, repoRel); + try { + const rangeFiles = await collectDoneRangeFiles(`${entry.baseCommitSha}..${entry.landedSha}`, repoRootDir); + repoFiles = rangeFiles.map((file) => ({ + path: file.path, + status: file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })); + } catch { + repoFiles = []; + } + } + + // Prefix every path with the sub-repo key so the Changes tab shows which repo each file is in. + return repoFiles.map((file) => ({ + ...file, + path: `${repoRel}/${file.path}`, + oldPath: file.oldPath ? `${repoRel}/${file.oldPath}` : undefined, + })); + }); + + return perRepo.flat(); +} + function extractCommitShaCandidate(event: { target?: unknown; metadata?: unknown; payload?: unknown; newValue?: unknown }): string | undefined { if (typeof event.target === "string" && event.target.trim()) { return event.target.trim(); @@ -730,6 +961,31 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute return; } + // FNXC:WorkspaceDiff 2026-06-25-09:40: + // Workspace tasks have no singular worktree/branch; their changes live in per-sub-repo + // worktrees. Aggregate across them (repo-prefixed paths) and short-circuit BEFORE the single-repo + // logic, which would diff the non-git workspace root and return empty. renamed→modified is folded + // to match the /diff contract (which has no 'renamed' status; /file-diffs keeps it). + if (isWorkspaceTask(task)) { + const workspaceFiles = await computeWorkspaceTaskFiles(task, scopedStore.getRootDir(), DIFF_TIMEOUT_MS); + const files = workspaceFiles.map((file) => ({ + path: file.path, + status: file.status === "renamed" ? "modified" : file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })); + res.json({ + files, + stats: { + filesChanged: files.length, + additions: files.reduce((sum, file) => sum + file.additions, 0), + deletions: files.reduce((sum, file) => sum + file.deletions, 0), + }, + }); + return; + } + if (task.column === "done") { const mergeShaForBaseBoundary = await resolveDoneTaskMergeSha(task, scopedStore, { includeBaseCommitSha: true }); const resolvedMergeSha = await resolveDoneTaskMergeSha(task, scopedStore); @@ -906,85 +1162,18 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute } const cwd = resolvedWorktree; - const diffBase = await resolveDiffBase(task, cwd, "HEAD", undefined, { enableDisplayRecovery: true }); - - // Only count files actually changed by the task: committed (base..HEAD) - // + staged + unstaged. Untracked files are intentionally excluded — at - // review time they're almost always build artifacts/cache/logs that - // weren't in .gitignore, not real task changes. - const fileMap = new Map(); - - if (diffBase) { - try { - const committedOutput = (await runGitCommand(["diff", "--name-status", "-M", `${diffBase}..HEAD`], cwd, 10000)).trim(); - for (const line of committedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed) continue; - fileMap.set(parsed.path, parsed.statusCode); - } - } catch { - // committed diff failed - } - } - - await restrictActiveCommittedFilesToOwnTask(fileMap, { - taskId: task.id, - diffBase, - worktreePath: cwd, - runGit: (args) => runGitCommand(args, cwd, 10000), - }); - - try { - const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 10000)).trim(); - for (const line of stagedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, parsed.statusCode); - } - } catch { - // staged diff failed - } - - try { - const workingTreeOutput = (await runGitCommand(["diff", "--name-status", "-M"], cwd, 10000)).trim(); - for (const line of workingTreeOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, parsed.statusCode); - } - } catch { - // working tree diff failed - } - - const files: Array<{ - path: string; - status: "added" | "modified" | "deleted"; - additions: number; - deletions: number; - patch: string; - }> = []; - - for (const [filePath, statusCode] of fileMap) { - if (!filePath) continue; - - let status: "added" | "modified" | "deleted"; - if (statusCode.startsWith("A")) status = "added"; - else if (statusCode.startsWith("D")) status = "deleted"; - else status = "modified"; - - let patch = ""; - try { - patch = diffBase - ? await runGitCommand(["diff", diffBase, "--", filePath], cwd, 10000) - : await runGitCommand(["diff", "HEAD", "--", filePath], cwd, 10000); - } catch { - // ignore individual file errors - } - - const { additions, deletions } = countPatchLines(patch); - - files.push({ path: filePath, status, additions, deletions, patch }); - } + // Single-repo detailed diff (committed base..HEAD + staged + unstaged), + // shared with the per-sub-repo workspace aggregation. Renames fold to + // "modified" here (the /diff shape has no "renamed" status), matching the + // previous inline behaviour. + const detailed = await computeWorktreeDetailedFiles(task, cwd, DIFF_TIMEOUT_MS); + const files = detailed.map((file) => ({ + path: file.path, + status: file.status === "renamed" ? ("modified" as const) : file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })); const stats = { filesChanged: files.length, @@ -1010,6 +1199,20 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute return; } + // FNXC:WorkspaceDiff 2026-06-25-09:40: + // Workspace tasks aggregate per-sub-repo patches (repo-prefixed paths); short-circuit before the + // single-repo logic that diffs the non-git root. Unlike /diff, /file-diffs preserves the + // 'renamed' status + oldPath. Empty-patch entries are dropped (parity with the single-repo path). + if (isWorkspaceTask(task)) { + const workspaceFiles = (await computeWorkspaceTaskFiles(task, scopedStore.getRootDir(), FILE_DIFFS_TIMEOUT_MS)) + .filter((file) => file.patch) + .map((file) => (file.oldPath + ? { path: file.path, status: file.status, diff: file.patch, oldPath: file.oldPath } + : { path: file.path, status: file.status, diff: file.patch })); + res.json(workspaceFiles); + return; + } + if (task.column === "done") { const mergeShaForBaseBoundary = await resolveDoneTaskMergeSha(task, scopedStore, { includeBaseCommitSha: true }); const resolvedMergeSha = await resolveDoneTaskMergeSha(task, scopedStore); @@ -1153,83 +1356,17 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute } const cwd = worktree; - const diffBase = await resolveDiffBase(task, cwd, "HEAD", undefined, { enableDisplayRecovery: true }); - // Only files actually changed by the task: committed + staged + unstaged. - // Untracked files (build artifacts, cache, logs) are intentionally - // excluded so the count matches "ACTUAL files changed by the task". - const fileMap = new Map(); - - if (diffBase) { - try { - const committedOutput = (await runGitCommand(["diff", "--name-status", "-M", `${diffBase}..HEAD`], cwd, 5000)).trim(); - for (const line of committedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed) continue; - fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); - } - } catch { - // continue with working-tree-only changes - } - } - - await restrictActiveCommittedFilesToOwnTask(fileMap, { - taskId: task.id, - diffBase, - worktreePath: cwd, - runGit: (args) => runGitCommand(args, cwd, 5000), - }); - - try { - const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 5000)).trim(); - for (const line of stagedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); - } - } catch { - // ignore staged diff failures - } - - try { - const workingTreeOutput = (await runGitCommand(["diff", "--name-status", "-M"], cwd, 5000)).trim(); - for (const line of workingTreeOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); - } - } catch { - // ignore unstaged diff failures - } - - const files = []; - - for (const [filePath, { statusCode, oldPath }] of fileMap.entries()) { - let status: "added" | "modified" | "deleted" | "renamed" = "modified"; - - if (statusCode.startsWith("A")) { - status = "added"; - } else if (statusCode.startsWith("D")) { - status = "deleted"; - } else if (statusCode.startsWith("R")) { - status = "renamed"; - } - - let diff = ""; - try { - diff = diffBase - ? await runGitCommand(["diff", diffBase, "--", filePath], cwd, 5000) - : await runGitCommand(["diff", "HEAD", "--", filePath], cwd, 5000); - } catch { - diff = ""; - } - - if (!diff) { - continue; - } - - files.push(oldPath ? { path: filePath, status, diff, oldPath } : { path: filePath, status, diff }); - } + // Single-repo per-file patches (committed base..HEAD + staged + unstaged), + // shared with the per-sub-repo workspace aggregation. Files with an empty + // patch (e.g. pure renames with no content change) are dropped, matching + // the previous inline behaviour. + const detailed = await computeWorktreeDetailedFiles(task, cwd, FILE_DIFFS_TIMEOUT_MS); + const files = detailed + .filter((file) => file.patch) + .map((file) => (file.oldPath + ? { path: file.path, status: file.status, diff: file.patch, oldPath: file.oldPath } + : { path: file.path, status: file.status, diff: file.patch })); fileDiffsCache.set(task.id, { files,