From 631f4066397cd4f98b095d3f7d3a43d0cf758805 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:15:26 -0400 Subject: [PATCH 01/20] Redesign chat cards and rebuild the proof artifact system Cards - One resizable --chat-content-width token replaces the fixed 832px column and seven disagreeing per-row clamps; cards no longer stop 26% short of prose. - Shared chatCardPrimitives on a 16px/1fr/auto grid so titles, durations and counts align in columns down the transcript. - CI cards render an honest degraded state instead of a false green when the GitHub fetch fails, and a degraded re-emit no longer blanks a rich card. - Subagent identity reads as role + intent; the raw Codex agent path moves to a tooltip. Placeholder filler no longer stands in for a result summary. - Real durations with an hour unit, formatted schedule times, wired card actions. Proof - Artifact paths resolve against the calling agent's lane worktree, and a missing file now throws instead of silently persisting an unrenderable record. - Imports are gated on file type: the widened roots put .env.local, *.db and key material beside legitimate captures, and the store syncs to paired phones. - Delete exists end to end: broker, IPC, action registry, ade proof rm/prune, drawer, and Settings storage. Artifacts carry lane_id and are removed with their lane; archive stays non-destructive. - Proof renders inline where it was captured rather than pinned to the thread footer, with a per-turn chip into the drawer. Co-Authored-By: Claude Opus 5 --- apps/ade-cli/src/adeRpcServer.test.ts | 45 +- apps/ade-cli/src/adeRpcServer.ts | 119 ++-- apps/ade-cli/src/bootstrap.ts | 5 + apps/ade-cli/src/cli.test.ts | 40 ++ apps/ade-cli/src/cli.ts | 66 +- .../src/services/sync/syncHostService.test.ts | 35 ++ .../src/services/sync/syncHostService.ts | 11 + apps/desktop/src/main/main.ts | 5 + .../src/main/services/adeActions/registry.ts | 13 +- .../computerUseArtifactBrokerService.test.ts | 239 +++++++- .../computerUseArtifactBrokerService.ts | 568 +++++++++++++++--- .../services/computerUse/controlPlane.test.ts | 61 +- .../main/services/computerUse/controlPlane.ts | 56 +- .../src/main/services/ipc/registerIpc.ts | 23 +- .../main/services/lanes/laneService.test.ts | 53 ++ .../src/main/services/lanes/laneService.ts | 124 ++++ .../services/projects/adeProjectService.ts | 18 +- .../proof/agentBrowserArtifactAdapter.test.ts | 409 ------------- .../proof/agentBrowserArtifactAdapter.ts | 95 --- .../src/main/services/prs/prChatCards.test.ts | 83 +++ .../src/main/services/prs/prChatCards.ts | 55 +- apps/desktop/src/main/services/state/kvDb.ts | 7 + .../storage/storageInsightsService.test.ts | 38 ++ .../storage/storageInsightsService.ts | 29 + apps/desktop/src/preload/global.d.ts | 17 +- apps/desktop/src/preload/preload.ts | 45 +- .../src/renderer/components/chat/AdeCard.tsx | 391 ++++++------ .../chat/AgentChatMessageList.test.tsx | 191 +++++- .../components/chat/AgentChatMessageList.tsx | 254 ++++++-- .../components/chat/AgentChatPane.tsx | 12 +- .../chat/ChatComputerUsePanel.test.tsx | 61 ++ .../components/chat/ChatComputerUsePanel.tsx | 291 ++++++++- .../components/chat/ChatFileChangesPanel.tsx | 43 +- .../components/chat/ChatWorkLogBlock.tsx | 30 +- .../components/chat/ContextCompactDivider.tsx | 3 +- .../components/chat/SubagentActivityCards.tsx | 283 +++++---- .../components/chat/chatAppearance.ts | 47 ++ .../chat/chatCardPrimitives.test.ts | 84 +++ .../components/chat/chatCardPrimitives.tsx | 551 +++++++++++++++++ .../chat/chatTranscriptRows.test.ts | 44 ++ .../components/chat/chatTranscriptRows.ts | 54 +- .../components/chat/codex/CodexPlanCard.tsx | 116 ++-- .../components/settings/StorageSection.tsx | 39 +- .../settings/storage/storageView.ts | 9 +- apps/desktop/src/renderer/lib/format.test.ts | 9 + apps/desktop/src/renderer/lib/format.ts | 15 +- apps/desktop/src/shared/adeCard.ts | 23 + apps/desktop/src/shared/ipc.ts | 5 +- .../src/shared/types/computerUseArtifacts.ts | 71 ++- apps/desktop/src/shared/types/storage.ts | 6 +- docs/ARCHITECTURE.md | 2 +- docs/features/computer-use/README.md | 7 +- docs/features/computer-use/artifact-broker.md | 4 +- 53 files changed, 3666 insertions(+), 1238 deletions(-) delete mode 100644 apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.test.ts delete mode 100644 apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts create mode 100644 apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 378146507..efb4a8cf5 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1772,26 +1772,39 @@ describe("adeRpcServer", () => { ); }); - it("rejects computer-use manifests outside the project root", async () => { + it("forwards the caller's root so relative capture paths resolve in the agent's lane worktree", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - const outsideManifest = path.join(path.dirname(fixture.runtime.projectRoot), `ade-artifacts-${Date.now()}.json`); - fs.writeFileSync(outsideManifest, JSON.stringify([{ kind: "screenshot", path: "/tmp/shot.png" }]), "utf8"); + const laneRoot = path.join(fixture.runtime.projectRoot, ".ade", "worktrees", "lane-a"); - try { - await initialize(handler, { callerId: "chat-session-1", role: "agent" }); - const response = await callTool(handler, "ingest_computer_use_artifacts", { - backendStyle: "external_cli", - backendName: "agent-browser", - manifestPath: `../${path.basename(outsideManifest)}`, - }); + await initialize(handler, { callerId: "chat-session-1", role: "agent" }); + await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + callerRoot: laneRoot, + inputs: [{ kind: "screenshot", title: "Lane proof", path: "shots/proof.png" }], + }); - expect(response.isError).toBe(true); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("project root"); - expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); - } finally { - fs.rmSync(outsideManifest, { force: true }); - } + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( + expect.objectContaining({ callerRoot: laneRoot }), + ); + }); + + it("rejects a relative caller root, which would resolve differently on each side", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "chat-session-1", role: "agent" }); + const response = await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + callerRoot: "../elsewhere", + inputs: [{ kind: "screenshot", title: "Proof", path: "shots/proof.png" }], + }); + + expect(response.isError).toBe(true); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("absolute"); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index bb78bf543..6111fcaa4 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -8,7 +8,6 @@ import { getLocalComputerUseCapabilities, toProjectArtifactUri, } from "../../desktop/src/main/services/computerUse/localComputerUse"; -import { loadAgentBrowserArtifactPayloadFromFile, parseAgentBrowserArtifactPayload } from "../../desktop/src/main/services/proof/agentBrowserArtifactAdapter"; import { ADE_ACTION_DOMAIN_NAMES, type AdeActionDomain, @@ -544,7 +543,7 @@ const TOOL_SPECS: ToolSpec[] = [ backendName: { type: "string", minLength: 1 }, toolName: { type: "string" }, command: { type: "string" }, - manifestPath: { type: "string" }, + callerRoot: { type: "string", description: "Absolute directory that relative input paths are resolved against. Defaults to the agent's workspace root." }, inputs: { type: "array", items: { @@ -620,6 +619,44 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, + { + name: "delete_computer_use_artifacts", + description: "Delete stored proof artifacts: removes the database records and the stored file. Idempotent.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + artifactId: { type: "string", minLength: 1 }, + artifactIds: { type: "array", items: { type: "string", minLength: 1 } }, + } + } + }, + { + name: "list_broken_computer_use_artifacts", + description: "List proof records whose stored file is missing or was never imported, with the path each can be recovered from when one survives.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + limit: { type: "number", minimum: 1, maximum: 2000, default: 200 }, + } + } + }, + { + name: "prune_broken_computer_use_artifacts", + description: "Delete every proof record whose file is missing or was never imported.", + inputSchema: { type: "object", additionalProperties: false, properties: {} } + }, + { + name: "recover_computer_use_artifact", + description: "Re-import a broken proof record's original file when it still exists on disk.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["artifactId"], + properties: { artifactId: { type: "string", minLength: 1 } } + } + }, { name: "get_computer_use_backend_status", description: "Describe external-first computer-use backends available to ADE and the local fallback status.", @@ -1396,6 +1433,7 @@ const READ_ONLY_TOOLS = new Set([ "getLinearIssueComments", "get_environment_info", "list_computer_use_artifacts", + "list_broken_computer_use_artifacts", "get_computer_use_backend_status", ]); @@ -4347,39 +4385,20 @@ async function runTool(args: { if (name === "ingest_computer_use_artifacts") { const backendStyle = assertComputerUseBackendStyle(toolArgs.backendStyle, "backendStyle"); const backendName = assertNonEmptyString(toolArgs.backendName, "backendName"); - const manifestPath = asOptionalTrimmedString(toolArgs.manifestPath); - let inputs = Array.isArray(toolArgs.inputs) ? toolArgs.inputs.map((entry) => safeObject(entry)) : []; - if (manifestPath) { - if (path.isAbsolute(manifestPath)) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "manifestPath must be relative to the project root"); - } - let resolvedManifest: string; - try { - resolvedManifest = resolvePathWithinRoot(runtime.projectRoot, path.resolve(runtime.projectRoot, manifestPath)); - } catch { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "manifestPath must stay within the project root"); - } - inputs = loadAgentBrowserArtifactPayloadFromFile(resolvedManifest).map((entry) => ({ - ...entry, - metadata: { - ...(isRecord(entry.metadata) ? entry.metadata : {}), - manifestPath: resolvedManifest, - }, - })); - } else if (backendName === "agent-browser" && inputs.length === 1 && isRecord(inputs[0]?.json)) { - const adapted = parseAgentBrowserArtifactPayload(inputs[0].json); - if (adapted.length > 0) { - inputs = adapted.map((entry) => ({ - ...entry, - metadata: { - ...(isRecord(entry.metadata) ? entry.metadata : {}), - adapter: "agent-browser-json", - }, - })); - } - } + const inputs = Array.isArray(toolArgs.inputs) ? toolArgs.inputs.map((entry) => safeObject(entry)) : []; if (inputs.length === 0) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "Provide inputs or manifestPath for computer-use ingestion."); + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "Provide inputs for computer-use ingestion."); + } + // Relative `input.path` values come from an agent whose cwd is its lane + // worktree, not the project root. Prefer an explicit callerRoot, then the + // caller's lane worktree, and only then the project root. + const callerRoot = asOptionalTrimmedString(toolArgs.callerRoot) + ?? resolveLaneWorktreePath( + runtime, + asOptionalTrimmedString(toolArgs.laneId) ?? resolveChatSessionLaneId(runtime, session), + ); + if (callerRoot && !path.isAbsolute(callerRoot)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "callerRoot must be an absolute path"); } const result = runtime.computerUseArtifactBrokerService.ingest({ backend: { @@ -4388,6 +4407,7 @@ async function runTool(args: { toolName: asOptionalTrimmedString(toolArgs.toolName), command: asOptionalTrimmedString(toolArgs.command), }, + ...(callerRoot ? { callerRoot } : {}), inputs: inputs.map((entry) => ({ kind: asOptionalTrimmedString(entry.kind), title: asOptionalTrimmedString(entry.title), @@ -4416,6 +4436,37 @@ async function runTool(args: { }; } + if (name === "delete_computer_use_artifacts") { + const ids = [ + ...(asOptionalTrimmedString(toolArgs.artifactId) ? [asOptionalTrimmedString(toolArgs.artifactId)!] : []), + ...(Array.isArray(toolArgs.artifactIds) + ? toolArgs.artifactIds.map((entry) => asOptionalTrimmedString(entry)).filter((entry): entry is string => Boolean(entry)) + : []), + ]; + if (!ids.length) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "Provide artifactId or artifactIds to delete."); + } + return runtime.computerUseArtifactBrokerService.deleteArtifacts({ artifactIds: ids }); + } + + if (name === "list_broken_computer_use_artifacts") { + return { + broken: runtime.computerUseArtifactBrokerService.listBrokenArtifacts({ + limit: asNumber(toolArgs.limit, 200), + }), + }; + } + + if (name === "prune_broken_computer_use_artifacts") { + return runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts(); + } + + if (name === "recover_computer_use_artifact") { + return runtime.computerUseArtifactBrokerService.recoverArtifact({ + artifactId: assertNonEmptyString(toolArgs.artifactId, "artifactId"), + }); + } + if (name === "get_computer_use_backend_status") { return runtime.computerUseArtifactBrokerService.getBackendStatus(); } diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 4b912a23b..5105dbb66 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1581,6 +1581,11 @@ export async function createAdeRuntime(args: { captureAnalytics: (input) => { productAnalyticsService.capture(input); }, + // Removing proof files from Settings must drop their records too, + // otherwise the drawer keeps listing items whose bytes are gone. + purgeProofRecordsUnder: (removedPath) => { + computerUseArtifactBrokerService.purgeArtifactRecordsUnder(removedPath); + }, }); const budgetCapService = createBudgetCapService({ db, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 28679f38b..2d07776ed 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -5922,6 +5922,46 @@ describe("ADE CLI", () => { }); }); + it("resolves a relative proof attach path against the caller's cwd", () => { + // The agent's cwd is its lane worktree; the runtime storing the artifact + // runs at the project root. Resolving here is what stops the runtime from + // having to guess which tree a bare "shots/proof.png" belongs to. + const plan = buildCliPlan(["proof", "attach", "shots/proof.png"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + + const args = plan.steps[0]?.params?.arguments as Record; + expect(args.callerRoot).toBe(process.cwd()); + expect((args.inputs as Array<{ path: string }>)[0]?.path).toBe( + path.resolve(process.cwd(), "shots/proof.png"), + ); + }); + + it("maps proof rm and prune --broken to the delete actions", () => { + const rm = buildCliPlan(["proof", "rm", "artifact-1", "artifact-2"]); + expect(rm.kind).toBe("execute"); + if (rm.kind !== "execute") return; + expect(rm.steps[0]?.params).toMatchObject({ + name: "delete_computer_use_artifacts", + arguments: { artifactIds: ["artifact-1", "artifact-2"] }, + }); + + const prune = buildCliPlan(["proof", "prune", "--broken"]); + expect(prune.kind).toBe("execute"); + if (prune.kind !== "execute") return; + expect(prune.steps[0]?.params).toMatchObject({ + name: "prune_broken_computer_use_artifacts", + }); + + // Bare `prune` only reports; removal has to be asked for explicitly. + const dryRun = buildCliPlan(["proof", "prune"]); + expect(dryRun.kind).toBe("execute"); + if (dryRun.kind !== "execute") return; + expect(dryRun.steps[0]?.params).toMatchObject({ + name: "list_broken_computer_use_artifacts", + }); + }); + it("rejects invalid --role values", () => { expect(() => parseCliArgs(["--role", "bogus", "lanes", "list"])).toThrow( /--role must be one of/, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 9bc4e8691..1659b0e74 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -8750,10 +8750,14 @@ function buildProofPlan(args: string[]): CliPlan { }; if (sub === "attach") { const caption = readValue(args, ["--caption", "--description", "--desc"]); - const attachedPath = requireValue( + const rawPath = requireValue( readValue(args, ["--path"]) ?? firstPositional(args), "path", ); + // The agent runs in its lane worktree; the runtime that stores the artifact + // runs at the project root. Resolve here, where the caller's cwd is known, + // so a relative path never has to be guessed at on the other side. + const attachedPath = path.resolve(process.cwd(), rawPath); const title = readValue(args, ["--title", "--name"]) ?? caption ?? @@ -8769,6 +8773,7 @@ function buildProofPlan(args: string[]): CliPlan { backendStyle: "manual", backendName: "ade-cli", toolName: "proof attach", + callerRoot: process.cwd(), ...proofOwnerBase(), inputs: [ { @@ -8783,6 +8788,65 @@ function buildProofPlan(args: string[]): CliPlan { ], }; } + if (sub === "rm" || sub === "remove" || sub === "delete") { + const ids: string[] = []; + const explicitId = readValue(args, ["--id", "--artifact-id"]); + if (explicitId) ids.push(explicitId); + for (;;) { + const next = firstPositional(args); + if (!next) break; + ids.push(next); + } + if (!ids.length) requireValue(null, "artifact id"); + return { + kind: "execute", + label: "proof rm", + steps: [ + actionCallStep( + "result", + "delete_computer_use_artifacts", + collectGenericObjectArgs(args, { artifactIds: ids }), + ), + ], + }; + } + if (sub === "prune") { + const broken = readFlag(args, ["--broken"]); + return { + kind: "execute", + label: broken ? "proof prune --broken" : "proof prune", + steps: [ + broken + ? actionCallStep("result", "prune_broken_computer_use_artifacts", {}) + : actionCallStep("result", "list_broken_computer_use_artifacts", {}), + ], + }; + } + if (sub === "broken") + return { + kind: "execute", + label: "proof broken", + steps: [ + actionCallStep( + "result", + "list_broken_computer_use_artifacts", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "recover") { + const artifactId = requireValue( + readValue(args, ["--id", "--artifact-id"]) ?? firstPositional(args), + "artifact id", + ); + return { + kind: "execute", + label: "proof recover", + steps: [ + actionCallStep("result", "recover_computer_use_artifact", { artifactId }), + ], + }; + } if (sub === "screenshot" || sub === "capture") { const caption = readValue(args, ["--caption", "--description", "--desc"]); return { diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index d2498292e..a473a10ea 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -7832,6 +7832,41 @@ describe("sync host reliability guards", () => { } }); + it("serves artifacts stored under the ade-artifact scheme the broker writes", async () => { + // Stored URIs are `ade-artifact://project/`. Without stripping + // the scheme the phone resolved a path starting with "project/" and every + // artifact read failed, even for captures that render fine on desktop. + const { projectRoot, cleanup } = createTempProjectRoot(); + const artifactPath = path.join(projectRoot, ".ade", "artifacts", "computer-use", "shot.png"); + fs.mkdirSync(path.dirname(artifactPath), { recursive: true }); + fs.writeFileSync(artifactPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const host = createReliabilityHost(projectRoot); + let peer: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-artifact-scheme"); + peer.ws.send(encodeSyncEnvelope({ + type: "file_request", + requestId: "artifact-scheme", + payload: { + action: "readArtifact", + args: { uri: "ade-artifact://project/.ade/artifacts/computer-use/shot.png" }, + }, + })); + + const response = await waitForEnvelope(peer.envelopes, "file_response", "artifact-scheme"); + expect(response.payload).toMatchObject({ ok: true, action: "readArtifact" }); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + it("rejects oversized artifact reads with a clear file response", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const artifactPath = path.join(projectRoot, ".ade", "artifacts", "large.bin"); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index d4e606550..f17c66c8b 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -5712,6 +5712,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (/^https?:\/\//i.test(candidate)) { throw new Error("Remote artifact URLs are not supported by this sync host."); } + // Stored URIs use the `ade-artifact://project/` form the renderer + // and the remote-command service both understand. Without stripping the + // scheme here the phone resolves a path that starts with "project/" and + // every artifact read fails. + if (/^ade-artifact:\/\/project(?:\/|$)/i.test(candidate)) { + try { + candidate = decodeURIComponent(new URL(candidate).pathname.replace(/^\/+/, "")); + } catch { + throw new Error("Artifact URI is invalid."); + } + } if (/^file:\/\//i.test(candidate)) { try { candidate = fileURLToPath(candidate); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 85cf1261b..eb020c526 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3768,6 +3768,11 @@ app.whenReady().then(async () => { captureAnalytics: (input) => { productAnalyticsService.capture(input); }, + // Removing proof files from Settings must drop their records too, + // otherwise the drawer keeps listing items whose bytes are gone. + purgeProofRecordsUnder: (removedPath) => { + computerUseArtifactBrokerService.purgeArtifactRecordsUnder(removedPath); + }, }); // Phone sync is owned by the per-machine ADE service. The desktop diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 8aa3fc4cc..fc9459f77 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -800,7 +800,18 @@ export const ADE_ACTION_ALLOWLIST: Partial { fs.rmSync(projectRoot, { recursive: true, force: true }); }); - it("routes ingested artifacts to additional owners and persists review metadata", () => { + it("persists review metadata for ingested artifacts", () => { const events: Array<{ type: string; artifactId: string }> = []; const broker = createComputerUseArtifactBrokerService({ @@ -74,13 +74,8 @@ describe("computerUseArtifactBrokerService", () => { expect(initial[0]?.reviewState).toBe("accepted"); expect(initial[0]?.workflowState).toBe("evidence_only"); - const routed = broker.routeArtifact({ - artifactId, - owner: { kind: "linear_issue", id: "issue-1" }, - }); - expect(routed.links.map((link) => `${link.ownerKind}:${link.ownerId}`)).toEqual([ + expect(initial[0]?.links.map((link) => `${link.ownerKind}:${link.ownerId}`)).toEqual([ "lane:lane-1", - "linear_issue:issue-1", ]); const reviewed = broker.updateArtifactReview({ @@ -101,7 +96,6 @@ describe("computerUseArtifactBrokerService", () => { expect(events.map((event) => event.type)).toEqual([ "artifact-linked", "artifact-ingested", - "artifact-linked", "artifact-reviewed", ]); }); @@ -257,6 +251,235 @@ describe("computerUseArtifactBrokerService", () => { expect(row?.backend_style).toBe("manual"); }); + it("resolves a relative capture path against the caller's lane worktree", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + + // Agents run inside the lane worktree, so this is where their relative + // paths point — resolving against projectRoot silently lost every capture. + const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + fs.mkdirSync(path.join(laneRoot, "shots"), { recursive: true }); + fs.writeFileSync(path.join(laneRoot, "shots", "proof.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: laneRoot, + inputs: [{ kind: "screenshot", title: "Lane proof", path: "shots/proof.png" }], + }); + + const stored = ingested.artifacts[0]!; + // The bytes were copied into the artifact store, not merely referenced. + expect(stored.uri).toMatch(/^\.ade\/artifacts\/computer-use\//); + expect(fs.existsSync(path.join(projectRoot, stored.uri))).toBe(true); + expect(broker.listArtifacts({ artifactId: stored.id })[0]?.availability).toBe("available"); + }); + + it("throws instead of persisting a record when the capture file is not found", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + + expect(() => + broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: path.join(projectRoot, ".ade", "worktrees", "lane-a"), + inputs: [{ kind: "screenshot", title: "Missing proof", path: "shots/gone.png" }], + }), + ).toThrow(/Artifact file not found: shots\/gone\.png/); + + // The whole point: no dead row survives a failed capture. + expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); + }); + + it("deletes an artifact's rows and its stored file, and stays idempotent", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + owners: [{ kind: "chat_session", id: "chat-1" }], + inputs: [{ kind: "console_logs", title: "Notes", text: "hello" }], + }); + const artifactId = ingested.artifacts[0]!.id; + const filePath = path.join(projectRoot, ingested.artifacts[0]!.uri); + expect(fs.existsSync(filePath)).toBe(true); + + const result = broker.deleteArtifacts({ artifactId }); + expect(result.deleted[0]).toMatchObject({ artifactId, fileRemoved: true }); + expect(fs.existsSync(filePath)).toBe(false); + expect(broker.listArtifacts({ artifactId })).toHaveLength(0); + expect( + db.all(`select id from computer_use_artifact_links where artifact_id = ?`, [artifactId]), + ).toHaveLength(0); + + // Deleting again is not an error — the file may already be gone. + const repeat = broker.deleteArtifacts({ artifactId }); + expect(repeat.missing).toEqual([artifactId]); + expect(repeat.failed).toEqual([]); + }); + + it("removes rows for records whose file was already deleted", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Notes", text: "hello" }], + }); + const artifactId = ingested.artifacts[0]!.id; + fs.rmSync(path.join(projectRoot, ingested.artifacts[0]!.uri), { force: true }); + + expect(broker.listArtifacts({ artifactId })[0]?.availability).toBe("missing_file"); + const result = broker.deleteArtifacts({ artifactId }); + expect(result.deleted[0]?.fileRemoved).toBe(false); + expect(broker.listArtifacts({ artifactId })).toHaveLength(0); + }); + + it("prunes broken records and reports where a recoverable one still lives", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + // A record shaped like the ones the old silent fallback wrote: a raw + // relative path that never made it into `.ade/artifacts`. + const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + fs.mkdirSync(laneRoot, { recursive: true }); + fs.writeFileSync(path.join(laneRoot, "survivor.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, ?, null, ?)`, + [ + "dead-1", + "project-1", + "Orphan", + "survivor.png", + JSON.stringify({ sourcePath: "survivor.png" }), + "2026-03-12T14:00:00.000Z", + ], + ); + + const broken = broker.listBrokenArtifacts(); + expect(broken).toHaveLength(1); + expect(broken[0]).toMatchObject({ artifactId: "dead-1", reason: "outside_artifact_store" }); + // Compared by suffix: the resolver realpaths, and on macOS the temp dir + // resolves through the /private symlink. + expect(broken[0]?.recoverablePath?.endsWith("/.ade/worktrees/lane-a/survivor.png")).toBe(true); + + // Recovery re-imports the surviving bytes rather than dropping the record. + const recovered = broker.recoverArtifact({ artifactId: "dead-1" }); + expect(recovered.availability).toBe("available"); + expect(broker.listBrokenArtifacts()).toHaveLength(0); + }); + + it("prunes broken records that cannot be recovered", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', null, ?)`, + ["dead-2", "project-1", "Gone", "nowhere/missing.png", "2026-03-12T14:00:00.000Z"], + ); + + const pruned = broker.pruneBrokenArtifacts(); + expect(pruned.deleted.map((entry) => entry.artifactId)).toEqual(["dead-2"]); + expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); + }); + + it("refuses to promote files from the project secrets directory", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const secretsDir = path.join(projectRoot, ".ade", "secrets"); + fs.mkdirSync(secretsDir, { recursive: true }); + const secretPath = path.join(secretsDir, "api-keys.v1.bin"); + fs.writeFileSync(secretPath, "token", "utf8"); + + expect(() => + broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Keys", path: secretPath }], + }), + ).toThrow(/not allowed as a proof source/); + }); + + // The import roots include the project root and every lane worktree, because + // agents capture next to the code they are changing. That puts credential + // files in the same directories as legitimate screenshots, and the artifact + // store syncs to paired phones — so the file-type gate, not the root list, is + // what keeps them out. + it.each([ + [".env.local", "GEMINI_API_KEY=live-key"], + ["ade.db", "sqlite"], + ["ade.db-wal", "wal"], + ["id_rsa", "-----BEGIN OPENSSH PRIVATE KEY-----"], + ["server.pem", "-----BEGIN CERTIFICATE-----"], + ])("refuses to import %s from an allowed root", (name, contents) => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const sensitivePath = path.join(projectRoot, name); + fs.writeFileSync(sensitivePath, contents, "utf8"); + + expect(() => + broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "screenshot", title: "Proof", path: sensitivePath }], + }), + ).toThrow(/not importable as proof/); + expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); + }); + + it("still imports real proof from the project root", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const shotPath = path.join(projectRoot, "capture.png"); + fs.writeFileSync(shotPath, "png-bytes", "utf8"); + + const result = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "screenshot", title: "Proof", path: shotPath }], + }); + + expect(result.artifacts).toHaveLength(1); + expect(result.artifacts[0].uri).toMatch(/^\.ade\/artifacts\/computer-use\//); + }); + it("rejects symlinked artifact paths that escape the project artifact directory", () => { const broker = createComputerUseArtifactBrokerService({ db, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index b2350d0a3..c7931ec54 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -4,6 +4,11 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { + ComputerUseArtifactAvailability, + ComputerUseArtifactBrokenRecord, + ComputerUseArtifactDeleteArgs, + ComputerUseArtifactDeleteOutcome, + ComputerUseArtifactDeleteResult, ComputerUseArtifactIngestionRequest, ComputerUseArtifactIngestionResult, ComputerUseArtifactInput, @@ -14,7 +19,6 @@ import type { ComputerUseArtifactRecord, ComputerUseArtifactReviewArgs, ComputerUseArtifactReviewState, - ComputerUseArtifactRouteArgs, ComputerUseArtifactView, ComputerUseBackendStyle, ComputerUseBackendStatus, @@ -52,9 +56,16 @@ type StoredArtifactRow = { storage_kind: string; mime_type: string | null; metadata_json: string; + lane_id: string | null; created_at: string; }; +const ARTIFACT_SELECT_COLUMNS = ` + id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, + metadata_json, lane_id, created_at +`; + const DEFAULT_REVIEW_STATE: ComputerUseArtifactReviewState = "accepted"; const DEFAULT_WORKFLOW_STATE: ComputerUseArtifactWorkflowState = "evidence_only"; const ARTIFACT_PREVIEW_SIZE_CAP = 10 * 1024 * 1024; @@ -105,6 +116,19 @@ function isAllowedExternalArtifactSource( }); } +/** + * Paths an agent may read but must never be able to copy into the artifact + * store — artifacts are previewed in the renderer and synced to paired phones, + * so promoting a secrets blob into one is an exfiltration path. + */ +function isDeniedArtifactSource(absolutePath: string, deniedRoots: string[]): boolean { + const normalized = path.resolve(absolutePath); + return deniedRoots.some((root) => { + const normalizedRoot = path.resolve(root); + return normalized === normalizedRoot || normalized.startsWith(normalizedRoot + path.sep); + }); +} + function resolveRendererArtifactPath(rawPath: string, projectRoot: string): string { let inputPath = rawPath; if (/^ade-artifact:\/\/project(?:\/|$)/i.test(inputPath)) { @@ -209,6 +233,37 @@ function dedupeOwners(owners: ComputerUseArtifactOwner[]): ComputerUseArtifactOw return result; } +/** + * Extensions an on-disk file may be imported with. + * + * Proof is renderable evidence — images, video, browser traces, logs. The + * import roots are deliberately wide (a lane worktree and the project root, + * because agents capture next to the code they are changing), which puts + * `.env.local`, `.ade/*.db` and any key material exactly one `ade proof attach` + * away from an artifact store that syncs to paired phones and gets linked from + * PRs. Denying roots alone cannot cover that: the sensitive files live in the + * same directories as the legitimate ones. So the allow-list is on the artifact + * itself, and anything that is not plausibly proof is refused outright. + * + * Inline text/JSON does not pass through here — {@link materializeInlineContent} + * writes its own file inside the artifacts dir and never reads a user path. + */ +const IMPORTABLE_ARTIFACT_EXTENSIONS: ReadonlySet = new Set([ + "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "avif", "heic", + "mp4", "webm", "mov", "avi", "mkv", + "zip", "har", + "log", "txt", "md", +]); + +/** Extension of an import candidate, lowercased and without the dot. */ +function importExtension(absolutePath: string): string { + return path.extname(absolutePath).replace(/^\./, "").trim().toLowerCase(); +} + +function isImportableArtifactFile(absolutePath: string): boolean { + return IMPORTABLE_ARTIFACT_EXTENSIONS.has(importExtension(absolutePath)); +} + function inferArtifactExtension(input: ComputerUseArtifactInput, kind: ComputerUseArtifactKind): string { const fromPath = toOptionalString(input.path) ?? toOptionalString(input.uri); if (fromPath) { @@ -251,6 +306,12 @@ export function createComputerUseArtifactBrokerService(args: { layout.artifactsDir, layout.cacheDir, layout.tmpDir, + // Agents run inside lane worktrees and capture next to the code they are + // changing, so a lane worktree is as legitimate a capture source as the + // project root itself. `worktreesDir` is listed explicitly because a lane + // worktree can be relocated outside `projectRoot`. + layout.worktreesDir, + projectRoot, os.tmpdir(), path.join(os.homedir(), ".agent-browser"), ...(args.additionalAllowedImportRoots ?? []) @@ -258,6 +319,7 @@ export function createComputerUseArtifactBrokerService(args: { .filter(Boolean) .map((root) => path.resolve(root)), ])); + const deniedImportRoots = [layout.secretsDir]; const emit = (payload: ComputerUseEventPayload): void => { try { @@ -278,7 +340,27 @@ export function createComputerUseArtifactBrokerService(args: { return toProjectArtifactUri(projectRoot, artifactPath); }; - const resolveStoredUri = (input: ComputerUseArtifactInput, kind: ComputerUseArtifactKind, title: string): { uri: string; storageKind: "file" | "url"; mimeType: string | null } => { + /** + * Roots a caller-supplied *relative* path is tried against, in order. + * + * Agents run inside lane worktrees, so a relative path they hand us is + * relative to the worktree, not to `projectRoot`. Resolving only against + * `projectRoot` is what silently discarded every capture taken outside + * `.ade/artifacts`. + */ + const relativeResolutionRoots = (callerRoot: string | null): string[] => { + const roots: string[] = []; + if (callerRoot) roots.push(path.resolve(callerRoot)); + if (!roots.includes(path.resolve(projectRoot))) roots.push(path.resolve(projectRoot)); + return roots; + }; + + const resolveStoredUri = ( + input: ComputerUseArtifactInput, + kind: ComputerUseArtifactKind, + title: string, + callerRoot: string | null, + ): { uri: string; storageKind: "file" | "url"; mimeType: string | null } => { const directUri = toOptionalString(input.uri); if (directUri && isHttpUrl(directUri)) { return { uri: directUri, storageKind: "url", mimeType: toOptionalString(input.mimeType) }; @@ -286,34 +368,69 @@ export function createComputerUseArtifactBrokerService(args: { const pathLike = toOptionalString(input.path) ?? (directUri && !isHttpUrl(directUri) ? directUri : null); if (pathLike) { - const absolutePath = path.isAbsolute(pathLike) - ? pathLike - : resolvePathWithinRoot(projectRoot, pathLike, { allowMissing: true }); - if (fileExists(absolutePath)) { - try { - const existingArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolutePath); - return { - uri: toProjectArtifactUri(projectRoot, existingArtifactPath), - storageKind: "file", - mimeType: toOptionalString(input.mimeType), - }; - } catch { - // Fall through to external import handling. - } - if (!isAllowedExternalArtifactSource(absolutePath, allowedImportRoots)) { - throw new Error(`Artifact path is outside allowed import roots: ${absolutePath}`); + const attempted: string[] = []; + let absolutePath: string | null = null; + if (path.isAbsolute(pathLike)) { + attempted.push(pathLike); + if (fileExists(pathLike)) absolutePath = pathLike; + } else { + for (const root of relativeResolutionRoots(callerRoot)) { + let candidate: string; + try { + candidate = resolvePathWithinRoot(root, pathLike, { allowMissing: true }); + } catch { + attempted.push(path.join(root, pathLike)); + continue; + } + attempted.push(candidate); + if (fileExists(candidate)) { + absolutePath = candidate; + break; + } } - const extension = inferArtifactExtension({ ...input, path: absolutePath }, kind); - const targetPath = createComputerUseArtifactPath(projectRoot, title, extension); - secureCopyFromDescriptor(absolutePath, targetPath); + } + + if (!absolutePath) { + // Never persist a record for bytes we could not find. A row whose file + // was never imported renders as a permanently broken tile and cannot be + // recovered later, so failing loudly at capture time is the only honest + // outcome. + throw new Error( + `Artifact file not found: ${pathLike}. Tried ${attempted.join(", ") || pathLike}. ` + + `Pass an absolute path, or a path relative to ${callerRoot ?? projectRoot}.`, + ); + } + + if (isDeniedArtifactSource(absolutePath, deniedImportRoots)) { + throw new Error(`Artifact path is not allowed as a proof source: ${absolutePath}`); + } + + if (!isImportableArtifactFile(absolutePath)) { + throw new Error( + `Artifact file type is not importable as proof: ${absolutePath}. ` + + `Proof must be an image, video, browser trace, or log ` + + `(${[...IMPORTABLE_ARTIFACT_EXTENSIONS].join(", ")}).`, + ); + } + + try { + const existingArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolutePath); return { - uri: toProjectArtifactUri(projectRoot, targetPath), + uri: toProjectArtifactUri(projectRoot, existingArtifactPath), storageKind: "file", mimeType: toOptionalString(input.mimeType), }; + } catch { + // Fall through to external import handling. + } + if (!isAllowedExternalArtifactSource(absolutePath, allowedImportRoots)) { + throw new Error(`Artifact path is outside allowed import roots: ${absolutePath}`); } + const extension = inferArtifactExtension({ ...input, path: absolutePath }, kind); + const targetPath = createComputerUseArtifactPath(projectRoot, title, extension); + secureCopyFromDescriptor(absolutePath, targetPath); return { - uri: pathLike, + uri: toProjectArtifactUri(projectRoot, targetPath), storageKind: "file", mimeType: toOptionalString(input.mimeType), }; @@ -326,6 +443,30 @@ export function createComputerUseArtifactBrokerService(args: { }; }; + /** + * Lane an artifact belongs to, so deleting the lane deletes its captures. + * Chat sessions are `terminal_sessions` rows and carry the lane binding; an + * explicit `lane` owner wins when one was supplied. + */ + const resolveLaneIdForOwners = (owners: ComputerUseArtifactOwner[]): string | null => { + const explicitLane = owners.find((owner) => owner.kind === "lane")?.id; + if (explicitLane) return explicitLane; + for (const owner of owners) { + if (owner.kind !== "chat_session") continue; + try { + const row = db.get<{ lane_id: string | null }>( + "select lane_id from terminal_sessions where id = ? limit 1", + [owner.id], + ); + if (row?.lane_id) return row.lane_id; + } catch { + // The lane binding is a convenience for cleanup, never a hard + // requirement for keeping the capture. + } + } + return null; + }; + const insertArtifactRecord = (record: ComputerUseArtifactRecordInsert): ComputerUseArtifactRecord => { const { backendStyle: rawBackendStyle, ...artifactRecord } = record; const backendStyle = normalizeBackendStyle(rawBackendStyle); @@ -339,8 +480,9 @@ export function createComputerUseArtifactBrokerService(args: { ` insert into computer_use_artifacts( id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, - original_type, title, description, uri, storage_kind, mime_type, metadata_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ next.id, @@ -356,6 +498,7 @@ export function createComputerUseArtifactBrokerService(args: { next.storageKind, next.mimeType, JSON.stringify(next.metadata ?? {}), + next.laneId ?? null, next.createdAt, ], ); @@ -395,9 +538,7 @@ export function createComputerUseArtifactBrokerService(args: { const readArtifactById = (artifactId: string): ComputerUseArtifactRecord | null => readArtifactRows( ` - select id, artifact_kind, backend_style, backend_name, source_tool_name, - original_type, title, description, uri, storage_kind, mime_type, - metadata_json, created_at + select ${ARTIFACT_SELECT_COLUMNS} from computer_use_artifacts where project_id = ? and id = ? @@ -406,6 +547,87 @@ export function createComputerUseArtifactBrokerService(args: { [projectId, artifactId], )[0] ?? null; + /** + * Absolute on-disk location an artifact's bytes should live at, or null when + * the record does not point inside the project artifact store. The read jail + * is the same one `readArtifactPreview` and the `ade-artifact://` protocol + * handler enforce — delete must never be able to unlink outside it. + */ + const resolveArtifactFilePath = (record: ComputerUseArtifactRecord): string | null => { + if (record.storageKind !== "file") return null; + const uri = record.uri?.trim(); + if (!uri || isHttpUrl(uri)) return null; + const candidate = resolveRendererArtifactPath(uri, projectRoot); + try { + return resolvePathWithinRoot(layout.artifactsDir, candidate, { allowMissing: true }); + } catch { + return null; + } + }; + + /** + * Where a broken record's original bytes might still be. Ingest records the + * caller's path and root in metadata, so a capture that was never imported + * can be re-imported as long as its lane worktree still exists. + */ + const findRecoverableSourcePath = (record: ComputerUseArtifactRecord): string | null => { + const candidates: string[] = []; + const push = (value: string | null | undefined): void => { + if (!value) return; + const trimmed = value.trim(); + if (!trimmed || isHttpUrl(trimmed)) return; + candidates.push(trimmed); + }; + push(toOptionalString(record.metadata?.absolutePath)); + push(toOptionalString(record.metadata?.sourcePath)); + push(record.uri); + + const metadataCallerRoot = toOptionalString(record.metadata?.callerRoot); + const roots = [ + ...(metadataCallerRoot ? [metadataCallerRoot] : []), + projectRoot, + // Any surviving lane worktree — the capture usually came from the lane + // the agent was running in, whose name we no longer know for old rows. + ...listLaneWorktreeRoots(), + ]; + + for (const candidate of candidates) { + if (path.isAbsolute(candidate)) { + if (fileExists(candidate)) return path.resolve(candidate); + continue; + } + for (const root of roots) { + let resolved: string; + try { + resolved = resolvePathWithinRoot(root, candidate, { allowMissing: true }); + } catch { + continue; + } + if (fileExists(resolved)) return resolved; + } + } + return null; + }; + + const listLaneWorktreeRoots = (): string[] => { + try { + return fs.readdirSync(layout.worktreesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(layout.worktreesDir, entry.name)); + } catch { + return []; + } + }; + + const resolveAvailability = (record: ComputerUseArtifactRecord): ComputerUseArtifactAvailability => { + if (record.storageKind === "url" || isHttpUrl(record.uri ?? "")) return "available"; + const filePath = resolveArtifactFilePath(record); + // A file-kind record whose URI does not land inside `.ade/artifacts` was + // never imported — this is the shape the pre-fix silent fallback persisted. + if (!filePath) return "unimported"; + return fileExists(filePath) ? "available" : "missing_file"; + }; + const toArtifactView = (record: ComputerUseArtifactRecord, links: ComputerUseArtifactLink[]): ComputerUseArtifactView => { const reviewState = toOptionalString(record.metadata.reviewState) as ComputerUseArtifactReviewState | null; const workflowState = toOptionalString(record.metadata.workflowState) as ComputerUseArtifactWorkflowState | null; @@ -415,30 +637,10 @@ export function createComputerUseArtifactBrokerService(args: { reviewState: reviewState ?? DEFAULT_REVIEW_STATE, workflowState: workflowState ?? DEFAULT_WORKFLOW_STATE, reviewNote: toOptionalString(record.metadata.reviewNote), + availability: resolveAvailability(record), }; }; - const getLink = (artifactId: string, owner: ComputerUseArtifactOwner): ComputerUseArtifactLink | null => - db.get( - ` - select id, artifact_id, owner_kind, owner_id, relation, metadata_json, created_at - from computer_use_artifact_links - where artifact_id = ? - and project_id = ? - and owner_kind = ? - and owner_id = ? - and relation = ? - limit 1 - `, - [artifactId, projectId, owner.kind, owner.id.trim(), owner.relation ?? "attached_to"], - ) - ? readLinkRows([artifactId]).find((link) => - link.ownerKind === owner.kind - && link.ownerId === owner.id.trim() - && link.relation === (owner.relation ?? "attached_to") - ) ?? null - : null; - const updateArtifactMetadata = (artifactId: string, updater: (current: Record) => Record): ComputerUseArtifactView => { const current = readArtifactById(artifactId); if (!current) { @@ -475,6 +677,7 @@ export function createComputerUseArtifactBrokerService(args: { storageKind: row.storage_kind as ComputerUseArtifactRecord["storageKind"], mimeType: row.mime_type, metadata: safeJsonParse(row.metadata_json, {}), + laneId: row.lane_id ?? null, createdAt: row.created_at, })); @@ -509,21 +712,9 @@ export function createComputerUseArtifactBrokerService(args: { if (local.proofRequirements.browser_verification.available) localKinds.push("browser_verification"); if (local.proofRequirements.console_logs.available) localKinds.push("console_logs"); + // Only backends ADE can actually ingest from are listed. A permanently + // `available: false` entry is decoration, not status. const backends: ComputerUseExternalBackendStatus[] = []; - const ghostInstalled = commandExists("ghost"); - backends.push({ - name: "Ghost OS", - available: false, - state: ghostInstalled ? "installed" : "missing", - detail: ghostInstalled - ? "Ghost OS CLI is installed, but ADE Ghost integration readiness is not enabled yet." - : "Ghost OS CLI is not installed on this machine.", - supportedKinds: [ - "screenshot", - "browser_verification", - ], - }); - const agentBrowserInstalled = commandExists("agent-browser"); backends.push({ name: "agent-browser", @@ -554,18 +745,124 @@ export function createComputerUseArtifactBrokerService(args: { }; }; + /** + * Remove artifacts: rows (artifact + every link) and the stored file. + * + * Idempotent — an id with no row lands in `missing`, and a row whose file + * is already gone still has its rows removed. File removal is jailed to + * `.ade/artifacts`, so a record that points elsewhere (an http URL, or one + * of the never-imported records the old silent fallback wrote) only ever + * loses its rows. + */ + const deleteArtifacts = (args: ComputerUseArtifactDeleteArgs): ComputerUseArtifactDeleteResult => { + const ids = Array.from(new Set([ + ...(toOptionalString(args?.artifactId) ? [toOptionalString(args.artifactId)!] : []), + ...((args?.artifactIds ?? []).map((id) => toOptionalString(id)).filter((id): id is string => Boolean(id))), + ])); + if (!ids.length) throw new Error("artifactId or artifactIds is required."); + + const deleted: ComputerUseArtifactDeleteOutcome[] = []; + const missing: string[] = []; + const failed: Array<{ artifactId: string; reason: string }> = []; + + for (const artifactId of ids) { + const record = readArtifactById(artifactId); + if (!record) { + missing.push(artifactId); + continue; + } + try { + const filePath = resolveArtifactFilePath(record); + let fileRemoved = false; + let freedBytes = 0; + if (filePath) { + try { + const stat = fs.statSync(filePath); + if (stat.isFile()) { + freedBytes = stat.size; + fs.rmSync(filePath, { force: true }); + fileRemoved = true; + } + } catch { + // Already gone (or never written). Rows still go. + } + } + db.run("delete from computer_use_artifact_links where artifact_id = ?", [artifactId]); + db.run("delete from computer_use_artifacts where id = ? and project_id = ?", [artifactId, projectId]); + deleted.push({ + artifactId, + title: record.title, + fileRemoved, + path: filePath, + freedBytes, + }); + emit({ type: "artifact-deleted", artifactId, at: nowIso(), owner: null }); + } catch (error) { + failed.push({ + artifactId, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + deleted, + missing, + failed, + freedBytes: deleted.reduce((sum, entry) => sum + entry.freedBytes, 0), + }; + }; + + /** + * Every record whose bytes cannot be served, with enough detail for the UI + * to say what happened and offer recovery when the original file survives. + */ + const listBrokenArtifacts = (args: { limit?: number } = {}): ComputerUseArtifactBrokenRecord[] => { + const limit = Math.max(1, Math.min(2000, Math.floor(args.limit ?? 1000))); + const records = readArtifactRows( + ` + select ${ARTIFACT_SELECT_COLUMNS} + from computer_use_artifacts + where project_id = ? + and storage_kind = 'file' + order by created_at desc + limit ? + `, + [projectId, limit], + ); + const broken: ComputerUseArtifactBrokenRecord[] = []; + for (const record of records) { + const availability = resolveAvailability(record); + if (availability === "available") continue; + broken.push({ + artifactId: record.id, + title: record.title, + kind: record.kind, + uri: record.uri, + createdAt: record.createdAt, + reason: availability === "unimported" ? "outside_artifact_store" : "missing_file", + laneId: record.laneId ?? null, + recoverablePath: findRecoverableSourcePath(record), + }); + } + return broken; + }; + return { ingest(request: ComputerUseArtifactIngestionRequest): ComputerUseArtifactIngestionResult { const owners = dedupeOwners(request.owners ?? []); + const callerRoot = toOptionalString(request.callerRoot); + const laneId = resolveLaneIdForOwners(owners); const artifacts = request.inputs.map((input) => { const kind = normalizeInputKind(input); const title = toOptionalString(input.title) ?? defaultTitleForKind(kind); - const { uri, storageKind, mimeType } = resolveStoredUri(input, kind, title); + const { uri, storageKind, mimeType } = resolveStoredUri(input, kind, title, callerRoot); const metadata = { ...(isRecord(input.metadata) ? input.metadata : {}), sourcePath: toOptionalString(input.path), sourceUri: toOptionalString(input.uri), rawType: toOptionalString(input.rawType), + ...(callerRoot ? { callerRoot } : {}), }; const record = insertArtifactRecord({ kind, @@ -579,6 +876,7 @@ export function createComputerUseArtifactBrokerService(args: { storageKind, mimeType, metadata, + laneId, }); for (const owner of owners) { insertLink(record.id, owner); @@ -618,7 +916,7 @@ export function createComputerUseArtifactBrokerService(args: { ` select a.id, a.artifact_kind, a.backend_style, a.backend_name, a.source_tool_name, a.original_type, a.title, a.description, a.uri, a.storage_kind, a.mime_type, - a.metadata_json, a.created_at + a.metadata_json, a.lane_id, a.created_at from computer_use_artifacts a inner join computer_use_artifact_links l on l.artifact_id = a.id @@ -637,9 +935,7 @@ export function createComputerUseArtifactBrokerService(args: { } else { artifacts = readArtifactRows( ` - select id, artifact_kind, backend_style, backend_name, source_tool_name, - original_type, title, description, uri, storage_kind, mime_type, - metadata_json, created_at + select ${ARTIFACT_SELECT_COLUMNS} from computer_use_artifacts where project_id = ? ${args.kind ? "and artifact_kind = ?" : ""} @@ -660,24 +956,130 @@ export function createComputerUseArtifactBrokerService(args: { return artifacts.map((artifact) => toArtifactView(artifact, linksByArtifact.get(artifact.id) ?? [])); }, - routeArtifact(args: ComputerUseArtifactRouteArgs): ComputerUseArtifactView { - const artifactId = String(args.artifactId ?? "").trim(); - if (!artifactId.length) throw new Error("artifactId is required."); - const owner = { ...args.owner, id: args.owner.id.trim() }; - if (!owner.id.length) throw new Error("owner.id is required."); + + deleteArtifacts, + listBrokenArtifacts, + + /** Drop every unrenderable record. Files were never there to remove. */ + pruneBrokenArtifacts(): ComputerUseArtifactDeleteResult { + const broken = listBrokenArtifacts({ limit: 2000 }); + if (!broken.length) return { deleted: [], missing: [], failed: [], freedBytes: 0 }; + return deleteArtifacts({ artifactIds: broken.map((entry) => entry.artifactId) }); + }, + + /** + * Re-import a broken record's original file when it still exists on disk, + * turning a dead row back into a viewable artifact. + */ + recoverArtifact(args: { artifactId: string }): ComputerUseArtifactView { + const artifactId = toOptionalString(args?.artifactId); + if (!artifactId) throw new Error("artifactId is required."); const record = readArtifactById(artifactId); if (!record) throw new Error(`Computer-use artifact not found: ${artifactId}`); - const existing = getLink(artifactId, owner); - if (!existing) { - insertLink(artifactId, owner); - emit({ - type: "artifact-linked", - artifactId, - at: nowIso(), - owner, - }); + if (resolveAvailability(record) === "available") { + return toArtifactView(record, readLinkRows([artifactId])); + } + const sourcePath = findRecoverableSourcePath(record); + if (!sourcePath) { + throw new Error( + `The original file for "${record.title}" no longer exists, so it cannot be recovered. Remove the record instead.`, + ); + } + if (!isAllowedExternalArtifactSource(sourcePath, allowedImportRoots) + || isDeniedArtifactSource(sourcePath, deniedImportRoots)) { + throw new Error(`Artifact path is outside allowed import roots: ${sourcePath}`); } - return toArtifactView(record, readLinkRows([artifactId])); + // Recovery re-imports bytes from a surviving lane worktree, so it is an + // import like any other and gets the same file-type gate. + if (!isImportableArtifactFile(sourcePath)) { + throw new Error(`Artifact file type is not importable as proof: ${sourcePath}`); + } + const extension = inferArtifactExtension({ path: sourcePath }, record.kind); + const targetPath = createComputerUseArtifactPath(projectRoot, record.title, extension); + secureCopyFromDescriptor(sourcePath, targetPath); + db.run( + "update computer_use_artifacts set uri = ?, storage_kind = 'file' where id = ? and project_id = ?", + [toProjectArtifactUri(projectRoot, targetPath), artifactId, projectId], + ); + const refreshed = readArtifactById(artifactId); + if (!refreshed) throw new Error(`Failed to refresh computer-use artifact: ${artifactId}`); + return toArtifactView(refreshed, readLinkRows([artifactId])); + }, + + /** + * Lane cascade. Deleting a lane deletes the captures taken in it — that is + * the behaviour users expect from "delete the lane and its stuff". + * Artifacts also linked to a chat outside the lane are kept so a shared + * capture never disappears from a chat the user did not delete. + */ + deleteArtifactsForLane(args: { laneId: string }): ComputerUseArtifactDeleteResult { + const laneId = toOptionalString(args?.laneId); + if (!laneId) throw new Error("laneId is required."); + const rows = db.all<{ id: string }>( + ` + select a.id + from computer_use_artifacts a + where a.project_id = ? + and ( + a.lane_id = ? + or exists ( + select 1 from computer_use_artifact_links l + where l.artifact_id = a.id + and l.owner_kind = 'lane' + and l.owner_id = ? + ) + ) + and not exists ( + select 1 + from computer_use_artifact_links l2 + join terminal_sessions s on s.id = l2.owner_id + where l2.artifact_id = a.id + and l2.owner_kind = 'chat_session' + and s.lane_id is not null + and s.lane_id <> ? + ) + `, + [projectId, laneId, laneId, laneId], + ); + if (!rows.length) return { deleted: [], missing: [], failed: [], freedBytes: 0 }; + return deleteArtifacts({ artifactIds: rows.map((row) => row.id) }); + }, + + /** Drop every artifact record for the project (used by "clear local data"). */ + deleteAllArtifacts(): ComputerUseArtifactDeleteResult { + const rows = db.all<{ id: string }>( + "select id from computer_use_artifacts where project_id = ?", + [projectId], + ); + if (!rows.length) return { deleted: [], missing: [], failed: [], freedBytes: 0 }; + return deleteArtifacts({ artifactIds: rows.map((row) => row.id) }); + }, + + /** + * Drop records whose stored file lived at or under `removedPath`. Called + * after Settings → Storage removes proof files so the rows never outlive + * the bytes. + */ + purgeArtifactRecordsUnder(removedPath: string): ComputerUseArtifactDeleteResult { + const root = path.resolve(removedPath); + const rows = readArtifactRows( + ` + select ${ARTIFACT_SELECT_COLUMNS} + from computer_use_artifacts + where project_id = ? + and storage_kind = 'file' + `, + [projectId], + ); + const ids = rows + .filter((record) => { + const filePath = resolveArtifactFilePath(record); + if (!filePath) return false; + return filePath === root || filePath.startsWith(root + path.sep); + }) + .map((record) => record.id); + if (!ids.length) return { deleted: [], missing: [], failed: [], freedBytes: 0 }; + return deleteArtifacts({ artifactIds: ids }); }, updateArtifactReview(args: ComputerUseArtifactReviewArgs): ComputerUseArtifactView { diff --git a/apps/desktop/src/main/services/computerUse/controlPlane.test.ts b/apps/desktop/src/main/services/computerUse/controlPlane.test.ts index 03cd28004..a822e5702 100644 --- a/apps/desktop/src/main/services/computerUse/controlPlane.test.ts +++ b/apps/desktop/src/main/services/computerUse/controlPlane.test.ts @@ -37,8 +37,33 @@ function createBackendStatus(): ComputerUseBackendStatus { }; } +function createArtifact(overrides: Record = {}) { + return { + id: "artifact-1", + kind: "screenshot", + backendStyle: "manual", + backendName: "ade-cli", + sourceToolName: null, + originalType: null, + title: "Checkout screen", + description: null, + uri: ".ade/artifacts/computer-use/shot.png", + storageKind: "file", + mimeType: "image/png", + metadata: {}, + laneId: "lane-1", + createdAt: "2026-03-12T14:00:00.000Z", + links: [], + reviewState: "accepted", + workflowState: "evidence_only", + reviewNote: null, + availability: "available", + ...overrides, + } as any; +} + describe("computer use control plane", () => { - it("shows live backend activity when a backend is available", () => { + it("reports backend readiness in the summary without fabricating activity events", () => { const snapshot = buildComputerUseOwnerSnapshot({ broker: { getBackendStatus: vi.fn(() => createBackendStatus()), @@ -48,7 +73,39 @@ describe("computer use control plane", () => { }); expect(snapshot.summary).toContain("Ghost OS is available and ready to capture proof"); - expect(snapshot.activity.some((item) => item.kind === "backend_available")).toBe(true); + // Backend readiness is a current condition, not something that happened. + // It belongs in the summary, never as a feed row stamped "just now". + expect(snapshot.activity).toEqual([]); + }); + + it("derives activity from stored artifacts and keeps their real timestamps", () => { + const snapshot = buildComputerUseOwnerSnapshot({ + broker: { + getBackendStatus: vi.fn(() => createBackendStatus()), + listArtifacts: vi.fn(() => [createArtifact()]), + } as any, + owner: { kind: "chat_session", id: "chat-1" }, + }); + + expect(snapshot.activity).toHaveLength(1); + expect(snapshot.activity[0]).toMatchObject({ + kind: "artifact_ingested", + artifactId: "artifact-1", + at: "2026-03-12T14:00:00.000Z", + severity: "success", + }); + }); + + it("flags artifacts whose bytes were never imported as warnings", () => { + const snapshot = buildComputerUseOwnerSnapshot({ + broker: { + getBackendStatus: vi.fn(() => createBackendStatus()), + listArtifacts: vi.fn(() => [createArtifact({ availability: "unimported" })]), + } as any, + owner: { kind: "chat_session", id: "chat-1" }, + }); + + expect(snapshot.activity[0]?.severity).toBe("warning"); }); it("collects only supported proof kinds from required phases", () => { diff --git a/apps/desktop/src/main/services/computerUse/controlPlane.ts b/apps/desktop/src/main/services/computerUse/controlPlane.ts index 8fd8e6bee..3d95b0d99 100644 --- a/apps/desktop/src/main/services/computerUse/controlPlane.ts +++ b/apps/desktop/src/main/services/computerUse/controlPlane.ts @@ -3,7 +3,6 @@ import type { ComputerUseArtifactOwner, ComputerUseActivityItem, ComputerUseArtifactView, - ComputerUseBackendStatus, ComputerUseOwnerSnapshot, } from "../../../shared/types"; import type { ComputerUseArtifactBrokerService } from "./computerUseArtifactBrokerService"; @@ -43,42 +42,15 @@ export function collectRequiredComputerUseKindsFromPhases( return Array.from(required); } -function buildActivity( - artifacts: ComputerUseArtifactView[], - backendStatus: ComputerUseBackendStatus, -) : ComputerUseActivityItem[] { - const liveBackendActivity: ComputerUseActivityItem[] = []; - for (const backend of backendStatus.backends.slice(0, 4)) { - const at = new Date().toISOString(); - if (backend.available && backend.state === "installed") { - liveBackendActivity.push({ - id: `backend:${backend.name}:available`, - at, - kind: "backend_available", - title: `${backend.name} ready`, - detail: backend.detail, - backendName: backend.name, - artifactId: null, - severity: "success", - }); - continue; - } - if (!backend.available || backend.state === "missing") { - liveBackendActivity.push({ - id: `backend:${backend.name}:unavailable`, - at, - kind: "backend_unavailable", - title: `${backend.name} unavailable`, - detail: backend.detail, - backendName: backend.name, - artifactId: null, - severity: "warning", - }); - continue; - } - } - - const artifactActivity = artifacts.slice(0, 6).map((artifact) => ({ +/** + * Activity is a record of things that happened, so every entry is derived from + * a stored artifact and carries that artifact's real timestamp. Backend + * readiness is a *current* condition, not an event — synthesizing feed rows for + * it stamped with `Date.now()` put fabricated "just now" entries at the top of + * every scope, which is why that half is gone. + */ +function buildActivity(artifacts: ComputerUseArtifactView[]): ComputerUseActivityItem[] { + return artifacts.slice(0, 8).map((artifact) => ({ id: `artifact:${artifact.id}`, at: artifact.createdAt, kind: "artifact_ingested" as const, @@ -86,12 +58,10 @@ function buildActivity( detail: `${artifact.backendName} produced ${artifact.title}.`, artifactId: artifact.id, backendName: artifact.backendName, - severity: artifact.reviewState === "accepted" ? "success" as const : "info" as const, + severity: artifact.availability === "missing_file" || artifact.availability === "unimported" + ? ("warning" as const) + : ("success" as const), })); - - return [...liveBackendActivity, ...artifactActivity] - .sort((left, right) => Date.parse(right.at) - Date.parse(left.at)) - .slice(0, 8); } export function buildComputerUseOwnerSnapshot(args: { @@ -138,6 +108,6 @@ export function buildComputerUseOwnerSnapshot(args: { activeBackend, artifacts, recentArtifacts, - activity: buildActivity(artifacts, backendStatus), + activity: buildActivity(artifacts), }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 560d6c635..b72ad8079 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -568,7 +568,9 @@ import type { BudgetCapConfig, ComputerUseArtifactListArgs, ComputerUseArtifactReviewArgs, - ComputerUseArtifactRouteArgs, + ComputerUseArtifactBrokenRecord, + ComputerUseArtifactDeleteArgs, + ComputerUseArtifactDeleteResult, ComputerUseArtifactView, ComputerUseOwnerSnapshot, ComputerUseOwnerSnapshotArgs, @@ -7757,9 +7759,24 @@ export function registerIpc({ }); }); - ipcMain.handle(IPC.computerUseRouteArtifact, async (_event, arg: ComputerUseArtifactRouteArgs): Promise => { + ipcMain.handle(IPC.computerUseDeleteArtifacts, async (_event, arg: ComputerUseArtifactDeleteArgs): Promise => { const ctx = ensureComputerUseBroker(); - return ctx.computerUseArtifactBrokerService.routeArtifact(arg); + return ctx.computerUseArtifactBrokerService.deleteArtifacts(arg); + }); + + ipcMain.handle(IPC.computerUseListBrokenArtifacts, async (_event, arg: { limit?: number } = {}): Promise => { + const ctx = ensureComputerUseBroker(); + return ctx.computerUseArtifactBrokerService.listBrokenArtifacts(arg); + }); + + ipcMain.handle(IPC.computerUsePruneBrokenArtifacts, async (): Promise => { + const ctx = ensureComputerUseBroker(); + return ctx.computerUseArtifactBrokerService.pruneBrokenArtifacts(); + }); + + ipcMain.handle(IPC.computerUseRecoverArtifact, async (_event, arg: { artifactId: string }): Promise => { + const ctx = ensureComputerUseBroker(); + return ctx.computerUseArtifactBrokerService.recoverArtifact(arg); }); ipcMain.handle(IPC.computerUseUpdateArtifactReview, async (_event, arg: ComputerUseArtifactReviewArgs): Promise => { diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 9926d4570..2f324001e 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3242,6 +3242,59 @@ describe("laneService delete teardown + cancellation + streaming", () => { return { db, service, repoRoot, worktreesDir, childPath }; } + it("deletes the lane's proof artifacts and their files, sparing proof shared with another lane", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { db, service, repoRoot } = await setupWithLane({ teardown: fake, events }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" }) as any); + + const artifactsDir = path.join(repoRoot, ".ade", "artifacts", "computer-use"); + fs.mkdirSync(artifactsDir, { recursive: true }); + const ownedFile = path.join(artifactsDir, "owned.png"); + const sharedFile = path.join(artifactsDir, "shared.png"); + fs.writeFileSync(ownedFile, "a"); + fs.writeFileSync(sharedFile, "b"); + + const insertArtifact = (id: string, laneId: string, relative: string) => { + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, 'proj-delete', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', ?, ?)`, + [id, id, relative, laneId, "2026-03-12T14:00:00.000Z"], + ); + }; + insertArtifact("art-owned", "lane-child", ".ade/artifacts/computer-use/owned.png"); + insertArtifact("art-shared", "lane-child", ".ade/artifacts/computer-use/shared.png"); + + // The shared artifact is also attached to a chat that lives in another + // lane, so deleting this lane must not take it away from that chat. + db.run( + `insert into terminal_sessions(id, lane_id, title, status, started_at, transcript_path) + values (?, ?, ?, 'exited', ?, ?)`, + ["chat-other", "lane-parent", "Other lane chat", "2026-03-12T14:00:00.000Z", "/tmp/other.log"], + ); + db.run( + `insert into computer_use_artifact_links( + id, artifact_id, project_id, owner_kind, owner_id, relation, metadata_json, created_at + ) values (?, ?, ?, 'chat_session', ?, 'attached_to', null, ?)`, + ["link-1", "art-shared", "proj-delete", "chat-other", "2026-03-12T14:00:00.000Z"], + ); + + await service.delete({ laneId: "lane-child", deleteBranch: false }); + + const remaining = db.all<{ id: string }>("select id from computer_use_artifacts").map((row) => row.id); + expect(remaining).toEqual(["art-shared"]); + expect(fs.existsSync(ownedFile)).toBe(false); + expect(fs.existsSync(sharedFile)).toBe(true); + }); + it("runs teardown steps before git_worktree_remove and broadcasts per-step progress", async () => { const events: any[] = []; const fake = makeFakeServices(); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index b0672f418..eafe70fb6 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -166,6 +166,39 @@ type SessionLinearIssueLinkRow = { const DEFAULT_LANE_STATUS: LaneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }; const LANE_LIST_CACHE_TTL_MS = 10_000; +/** + * Proof artifacts a lane owns, so deleting the lane can delete them. + * + * A capture belongs to the lane when its `lane_id` says so (populated at + * ingest) or when it carries an explicit `lane` link (pre-migration rows). A + * capture that is *also* attached to a chat session living in a different lane + * is excluded — deleting one lane must not remove proof another lane's chat + * still shows. + * + * Parameter order: projectId, laneId, laneId, laneId. + */ +const LANE_OWNED_ARTIFACT_IDS_SQL = ` + select a.id + from computer_use_artifacts a + where a.project_id = ? + and ( + a.lane_id = ? + or exists ( + select 1 from computer_use_artifact_links l + where l.artifact_id = a.id and l.owner_kind = 'lane' and l.owner_id = ? + ) + ) + and not exists ( + select 1 + from computer_use_artifact_links l2 + join terminal_sessions s on s.id = l2.owner_id + where l2.artifact_id = a.id + and l2.owner_kind = 'chat_session' + and s.lane_id is not null + and s.lane_id <> ? + ) +`; + function isPermissionError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException | undefined)?.code; return code === "EACCES" || code === "EPERM"; @@ -3011,6 +3044,68 @@ export function createLaneService({ } }; + /** + * Absolute paths of the proof files owned by a lane, resolved through the + * same `.ade/artifacts` jail the broker and the `ade-artifact://` handler + * enforce. Anything that does not land inside the artifact store is skipped + * rather than unlinked — a lane delete must never be able to remove a file + * outside ADE's own storage. + * + * Must be called before `cleanupLaneDatabaseRows`, which removes the rows + * these paths are read from. + */ + const collectLaneArtifactFilePaths = (laneId: string): string[] => { + const artifactsDir = resolveAdeLayout(projectRoot).artifactsDir; + let rows: Array<{ uri: string | null }> = []; + try { + rows = db.all<{ uri: string | null }>( + ` + select uri from computer_use_artifacts + where storage_kind = 'file' + and id in (${LANE_OWNED_ARTIFACT_IDS_SQL}) + `, + [projectId, laneId, laneId, laneId], + ); + } catch (error) { + logger.warn("lane.delete.artifact_paths_query_failed", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + const paths: string[] = []; + for (const row of rows) { + const uri = typeof row.uri === "string" ? row.uri.trim() : ""; + if (!uri || /^https?:\/\//i.test(uri)) continue; + let relative = uri; + if (/^ade-artifact:\/\/project(?:\/|$)/i.test(relative)) { + try { + relative = decodeURIComponent(new URL(relative).pathname.replace(/^\/+/, "")); + } catch { + continue; + } + } + const absolute = path.resolve(path.isAbsolute(relative) ? relative : path.join(projectRoot, relative)); + if (!isWithinDir(artifactsDir, absolute)) continue; + paths.push(absolute); + } + return paths; + }; + + const removeLaneArtifactFiles = (laneId: string, filePaths: string[]): void => { + for (const filePath of filePaths) { + try { + fs.rmSync(filePath, { force: true }); + } catch (error) { + logger.warn("lane.delete.artifact_file_remove_failed", { + laneId, + filePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + const cleanupLaneDatabaseRows = (laneId: string): void => { db.run("update lanes set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); db.run("update lane_branch_profiles set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); @@ -3050,6 +3145,27 @@ export function createLaneService({ `, [projectId, laneId, laneId, laneId], ); + // Proof captured in this lane goes with the lane. Both statements have to + // run while `terminal_sessions` still exists: the guard that spares a + // capture shared with a chat in another lane joins that table, and the + // session delete below is what would otherwise orphan the links. + db.run(`delete from computer_use_artifacts where id in (${LANE_OWNED_ARTIFACT_IDS_SQL})`, [ + projectId, laneId, laneId, laneId, + ]); + // Links for the deleted artifacts, plus links from surviving (shared) + // artifacts to the chat sessions this lane is about to remove. + db.run( + ` + delete from computer_use_artifact_links + where artifact_id not in (select id from computer_use_artifacts) + or ( + owner_kind = 'chat_session' + and owner_id in (select id from terminal_sessions where lane_id = ?) + ) + or (owner_kind = 'lane' and owner_id = ?) + `, + [laneId, laneId], + ); db.run("delete from claude_sessions where lane_id = ?", [laneId]); db.run("delete from terminal_sessions where lane_id = ?", [laneId]); db.run("delete from operations where lane_id = ? and project_id = ?", [laneId, projectId]); @@ -6103,6 +6219,10 @@ export function createLaneService({ }); await runStep("database_cleanup", async () => { + // Read the proof file paths before the rows go, then unlink only + // after the transaction commits: a rollback must not leave files + // deleted out from under rows that survived. + const laneArtifactFiles = collectLaneArtifactFilePaths(laneId); db.run("begin immediate"); try { cleanupLaneDatabaseRows(laneId); @@ -6115,6 +6235,10 @@ export function createLaneService({ } throw error; } + removeLaneArtifactFiles(laneId, laneArtifactFiles); + return laneArtifactFiles.length + ? { detail: `${laneArtifactFiles.length} proof file(s) removed` } + : undefined; }); invalidateLaneListCache(); diff --git a/apps/desktop/src/main/services/projects/adeProjectService.ts b/apps/desktop/src/main/services/projects/adeProjectService.ts index 3e78121e4..9eaaa4275 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.ts @@ -461,7 +461,23 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { deletedPaths.push(resolved); }; - if (options.packs) rmrf(repair.paths.artifactsDir); + if (options.packs) { + rmrf(repair.paths.artifactsDir); + // Removing `.ade/artifacts` without the rows leaves every proof record + // pointing at bytes that no longer exist — the inverse of the orphan a + // lane delete used to create. Drop the records with the files. + try { + args.db.run( + "delete from computer_use_artifact_links where artifact_id in (select id from computer_use_artifacts where project_id = ?)", + [args.projectId], + ); + args.db.run("delete from computer_use_artifacts where project_id = ?", [args.projectId]); + } catch (error) { + args.logger?.warn("ade.project.clear_local_data.artifact_rows_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } if (options.logs) rmrf(repair.paths.logsDir); if (options.transcripts) rmrf(repair.paths.transcriptsDir); diff --git a/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.test.ts b/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.test.ts deleted file mode 100644 index d8cf33746..000000000 --- a/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { - loadAgentBrowserArtifactPayloadFromFile, - parseAgentBrowserArtifactPayload, -} from "./agentBrowserArtifactAdapter"; - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — null / undefined / non-record inputs -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — non-object inputs", () => { - it("returns an empty array for null", () => { - expect(parseAgentBrowserArtifactPayload(null)).toEqual([]); - }); - - it("returns an empty array for undefined", () => { - expect(parseAgentBrowserArtifactPayload(undefined)).toEqual([]); - }); - - it("returns an empty array for primitive strings", () => { - expect(parseAgentBrowserArtifactPayload("not-a-payload")).toEqual([]); - }); - - it("returns an empty array for numbers", () => { - expect(parseAgentBrowserArtifactPayload(42)).toEqual([]); - }); - - it("returns an empty array for booleans", () => { - expect(parseAgentBrowserArtifactPayload(true)).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — array inputs -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — array inputs", () => { - it("coerces an array of records into artifact inputs", () => { - const inputs = parseAgentBrowserArtifactPayload([ - { - kind: "screenshot", - title: "After click", - path: "/tmp/shot.png", - mimeType: "image/png", - }, - { - kind: "console_logs", - text: "log line", - }, - ]); - expect(inputs).toHaveLength(2); - expect(inputs[0]).toMatchObject({ - kind: "screenshot", - title: "After click", - path: "/tmp/shot.png", - mimeType: "image/png", - }); - expect(inputs[1]).toMatchObject({ - kind: "console_logs", - text: "log line", - }); - }); - - it("skips non-record entries inside the array", () => { - const inputs = parseAgentBrowserArtifactPayload([ - null, - "string-entry", - 42, - { path: "/tmp/kept.png" }, - ]); - expect(inputs).toHaveLength(1); - expect(inputs[0]!.path).toBe("/tmp/kept.png"); - }); - - it("drops array entries with no path/uri/text/json", () => { - const inputs = parseAgentBrowserArtifactPayload([ - { kind: "screenshot", title: "Just metadata" }, - { description: "only description" }, - ]); - expect(inputs).toEqual([]); - }); - - it("keeps an array entry that carries only json payload", () => { - const inputs = parseAgentBrowserArtifactPayload([ - { kind: "browser_verification", json: { ok: true } }, - ]); - expect(inputs).toHaveLength(1); - expect(inputs[0]!.json).toEqual({ ok: true }); - }); -}); - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — record.artifacts[] -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — record with artifacts[]", () => { - it("coerces each entry in the artifacts array", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { kind: "screenshot", path: "/tmp/a.png" }, - { kind: "console_logs", text: "error" }, - ], - }); - expect(inputs).toHaveLength(2); - expect(inputs[0]!.path).toBe("/tmp/a.png"); - expect(inputs[1]!.text).toBe("error"); - }); - - it("handles artifacts being undefined without throwing", () => { - const inputs = parseAgentBrowserArtifactPayload({ artifacts: undefined }); - expect(inputs).toEqual([]); - }); - - it("handles artifacts being a non-array value", () => { - const inputs = parseAgentBrowserArtifactPayload({ artifacts: "not-an-array" }); - expect(inputs).toEqual([]); - }); - - it("uses field aliases: type→kind, name→title, summary→description, filePath→path, url→uri, contentType→mimeType", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { - type: "screenshot", - name: "Aliased entry", - summary: "summary-as-description", - filePath: "/tmp/alias.png", - url: "https://example.test/a.png", - contentType: "image/png", - }, - ], - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: "screenshot", - title: "Aliased entry", - description: "summary-as-description", - path: "/tmp/alias.png", - uri: "https://example.test/a.png", - mimeType: "image/png", - // rawType falls back to `type` when rawType is absent. - rawType: "screenshot", - }); - }); - - it("prefers canonical fields over aliases when both are present", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { - kind: "primary-kind", - type: "alias-kind", - title: "primary-title", - name: "alias-name", - path: "/primary/path.png", - filePath: "/alias/path.png", - uri: "https://primary.test/", - url: "https://alias.test/", - mimeType: "image/png", - contentType: "image/jpeg", - rawType: "primary-raw", - }, - ], - }); - expect(inputs[0]).toMatchObject({ - kind: "primary-kind", - title: "primary-title", - path: "/primary/path.png", - uri: "https://primary.test/", - mimeType: "image/png", - rawType: "primary-raw", - }); - }); - - it("treats whitespace-only strings as null", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { - kind: " ", - title: "\t\n ", - path: "/tmp/valid.png", - uri: " ", - text: " ", - }, - ], - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: null, - title: null, - path: "/tmp/valid.png", - uri: null, - text: null, - }); - }); - - it("trims surrounding whitespace from accepted strings", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { - kind: " screenshot ", - path: " /tmp/padded.png ", - }, - ], - }); - expect(inputs[0]).toMatchObject({ - kind: "screenshot", - path: "/tmp/padded.png", - }); - }); - - it("preserves record metadata when present and drops non-record metadata", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { path: "/tmp/has-meta.png", metadata: { region: "checkout" } }, - { path: "/tmp/no-meta.png", metadata: "stringly" }, - ], - }); - expect(inputs).toHaveLength(2); - expect(inputs[0]!.metadata).toEqual({ region: "checkout" }); - expect(inputs[1]!.metadata).toBeNull(); - }); - - it("drops entries that have no path/uri/text/json even with a kind or title", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [ - { kind: "screenshot", title: "metadata-only" }, - { description: "no payload" }, - ], - }); - expect(inputs).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — direct path mapping fields -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — direct path mappings", () => { - it("maps screenshotPath to a screenshot input with sourceField metadata", () => { - const inputs = parseAgentBrowserArtifactPayload({ - screenshotPath: "/tmp/ss.png", - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: "screenshot", - title: "Agent-browser screenshot", - path: "/tmp/ss.png", - rawType: "screenshotPath", - metadata: { sourceField: "screenshotPath" }, - }); - }); - - it("maps imagePath, videoPath, tracePath, consoleLogsPath, consoleLogPath, verificationPath", () => { - const inputs = parseAgentBrowserArtifactPayload({ - imagePath: "/tmp/image.png", - videoPath: "/tmp/rec.mp4", - tracePath: "/tmp/trace.zip", - consoleLogsPath: "/tmp/logs1.txt", - consoleLogPath: "/tmp/logs2.txt", - verificationPath: "/tmp/verify.json", - }); - const byField = new Map(inputs.map((input) => [input.rawType, input])); - expect(byField.get("imagePath")).toMatchObject({ kind: "screenshot", path: "/tmp/image.png" }); - expect(byField.get("videoPath")).toMatchObject({ kind: "video_recording", path: "/tmp/rec.mp4" }); - expect(byField.get("tracePath")).toMatchObject({ kind: "browser_trace", path: "/tmp/trace.zip" }); - expect(byField.get("consoleLogsPath")).toMatchObject({ kind: "console_logs", path: "/tmp/logs1.txt" }); - expect(byField.get("consoleLogPath")).toMatchObject({ kind: "console_logs", path: "/tmp/logs2.txt" }); - expect(byField.get("verificationPath")).toMatchObject({ kind: "browser_verification", path: "/tmp/verify.json" }); - expect(inputs).toHaveLength(6); - }); - - it("skips direct mapping fields that are empty or whitespace-only", () => { - const inputs = parseAgentBrowserArtifactPayload({ - screenshotPath: "", - videoPath: " ", - tracePath: null, - verificationPath: "/tmp/verify.json", - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: "browser_verification", - path: "/tmp/verify.json", - }); - }); -}); - -// --------------------------------------------------------------------------- -// parseAgentBrowserArtifactPayload — direct text mapping fields -// --------------------------------------------------------------------------- - -describe("parseAgentBrowserArtifactPayload — direct text mappings", () => { - it("maps consoleLogs, consoleLog, and verificationText to text inputs", () => { - const inputs = parseAgentBrowserArtifactPayload({ - consoleLogs: "plural logs", - consoleLog: "singular log", - verificationText: "ok", - }); - const byField = new Map(inputs.map((input) => [input.rawType, input])); - expect(byField.get("consoleLogs")).toMatchObject({ - kind: "console_logs", - title: "Agent-browser console logs", - text: "plural logs", - metadata: { sourceField: "consoleLogs" }, - }); - expect(byField.get("consoleLog")).toMatchObject({ - kind: "console_logs", - text: "singular log", - }); - expect(byField.get("verificationText")).toMatchObject({ - kind: "browser_verification", - text: "ok", - }); - expect(inputs).toHaveLength(3); - }); - - it("skips direct text fields that are empty or whitespace-only", () => { - const inputs = parseAgentBrowserArtifactPayload({ - consoleLogs: "", - consoleLog: " \n\t", - verificationText: "verified", - }); - expect(inputs).toHaveLength(1); - expect(inputs[0]).toMatchObject({ - kind: "browser_verification", - text: "verified", - }); - }); - - it("combines artifacts[], direct path mappings, and direct text mappings", () => { - const inputs = parseAgentBrowserArtifactPayload({ - artifacts: [{ kind: "screenshot", path: "/tmp/from-array.png" }], - screenshotPath: "/tmp/from-direct.png", - consoleLogs: "direct log", - }); - expect(inputs).toHaveLength(3); - const paths = inputs.map((input) => input.path); - expect(paths).toContain("/tmp/from-array.png"); - expect(paths).toContain("/tmp/from-direct.png"); - const textEntries = inputs.filter((input) => input.text); - expect(textEntries).toHaveLength(1); - expect(textEntries[0]!.text).toBe("direct log"); - }); -}); - -// --------------------------------------------------------------------------- -// loadAgentBrowserArtifactPayloadFromFile -// --------------------------------------------------------------------------- - -describe("loadAgentBrowserArtifactPayloadFromFile", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-agent-browser-adapter-")); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it("reads a JSON file and parses the payload", () => { - const filePath = path.join(tmpDir, "payload.json"); - fs.writeFileSync( - filePath, - JSON.stringify({ - screenshotPath: "/tmp/shot.png", - verificationText: "verified", - }), - "utf8", - ); - const inputs = loadAgentBrowserArtifactPayloadFromFile(filePath); - expect(inputs).toHaveLength(2); - const kinds = inputs.map((input) => input.kind).sort(); - expect(kinds).toEqual(["browser_verification", "screenshot"]); - }); - - it("parses array-at-root JSON files", () => { - const filePath = path.join(tmpDir, "array.json"); - fs.writeFileSync( - filePath, - JSON.stringify([ - { kind: "screenshot", path: "/tmp/a.png" }, - { kind: "console_logs", text: "line" }, - ]), - "utf8", - ); - const inputs = loadAgentBrowserArtifactPayloadFromFile(filePath); - expect(inputs).toHaveLength(2); - }); - - it("returns an empty array when the file contains JSON null", () => { - const filePath = path.join(tmpDir, "null.json"); - fs.writeFileSync(filePath, "null", "utf8"); - expect(loadAgentBrowserArtifactPayloadFromFile(filePath)).toEqual([]); - }); - - it("throws a SyntaxError when the file contains invalid JSON", () => { - const filePath = path.join(tmpDir, "bad.json"); - fs.writeFileSync(filePath, "{ not valid json", "utf8"); - expect(() => loadAgentBrowserArtifactPayloadFromFile(filePath)).toThrow(SyntaxError); - }); - - it("throws when the file does not exist", () => { - const filePath = path.join(tmpDir, "missing.json"); - expect(() => loadAgentBrowserArtifactPayloadFromFile(filePath)).toThrow(/ENOENT|no such file/i); - }); -}); diff --git a/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts b/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts deleted file mode 100644 index 3d784a7ce..000000000 --- a/apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts +++ /dev/null @@ -1,95 +0,0 @@ -import fs from "node:fs"; -import type { ComputerUseArtifactInput } from "../../../shared/types"; -import { isRecord } from "../shared/utils"; - -function toOptionalString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function pushInput(target: ComputerUseArtifactInput[], input: ComputerUseArtifactInput | null): void { - if (!input) return; - if (!input.path && !input.uri && !input.text && input.json == null) return; - target.push(input); -} - -function coerceArtifactEntry(entry: unknown): ComputerUseArtifactInput | null { - if (!isRecord(entry)) return null; - return { - kind: toOptionalString(entry.kind) ?? toOptionalString(entry.type), - title: toOptionalString(entry.title) ?? toOptionalString(entry.name), - description: toOptionalString(entry.description) ?? toOptionalString(entry.summary), - path: toOptionalString(entry.path) ?? toOptionalString(entry.filePath), - uri: toOptionalString(entry.uri) ?? toOptionalString(entry.url), - text: toOptionalString(entry.text), - json: entry.json, - mimeType: toOptionalString(entry.mimeType) ?? toOptionalString(entry.contentType), - rawType: toOptionalString(entry.rawType) ?? toOptionalString(entry.type), - metadata: isRecord(entry.metadata) ? entry.metadata : null, - }; -} - -export function parseAgentBrowserArtifactPayload(payload: unknown): ComputerUseArtifactInput[] { - const inputs: ComputerUseArtifactInput[] = []; - if (!payload) return inputs; - - if (Array.isArray(payload)) { - for (const entry of payload) { - pushInput(inputs, coerceArtifactEntry(entry)); - } - return inputs; - } - - if (!isRecord(payload)) return inputs; - - if (Array.isArray(payload.artifacts)) { - for (const entry of payload.artifacts) { - pushInput(inputs, coerceArtifactEntry(entry)); - } - } - - const directMappings: Array<[string, string, string]> = [ - ["screenshotPath", "screenshot", "Agent-browser screenshot"], - ["imagePath", "screenshot", "Agent-browser screenshot"], - ["videoPath", "video_recording", "Agent-browser video"], - ["tracePath", "browser_trace", "Agent-browser trace"], - ["consoleLogsPath", "console_logs", "Agent-browser console logs"], - ["consoleLogPath", "console_logs", "Agent-browser console logs"], - ["verificationPath", "browser_verification", "Agent-browser verification"], - ]; - for (const [field, kind, title] of directMappings) { - const pathValue = toOptionalString(payload[field]); - if (!pathValue) continue; - pushInput(inputs, { - kind, - title, - path: pathValue, - rawType: field, - metadata: { sourceField: field }, - }); - } - - const directTextMappings: Array<[string, string, string]> = [ - ["consoleLogs", "console_logs", "Agent-browser console logs"], - ["consoleLog", "console_logs", "Agent-browser console logs"], - ["verificationText", "browser_verification", "Agent-browser verification"], - ]; - for (const [field, kind, title] of directTextMappings) { - const textValue = toOptionalString(payload[field]); - if (!textValue) continue; - pushInput(inputs, { - kind, - title, - text: textValue, - rawType: field, - metadata: { sourceField: field }, - }); - } - - return inputs; -} - -export function loadAgentBrowserArtifactPayloadFromFile(filePath: string): ComputerUseArtifactInput[] { - const raw = fs.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - return parseAgentBrowserArtifactPayload(parsed); -} diff --git a/apps/desktop/src/main/services/prs/prChatCards.test.ts b/apps/desktop/src/main/services/prs/prChatCards.test.ts index 53b525faf..10e7409d3 100644 --- a/apps/desktop/src/main/services/prs/prChatCards.test.ts +++ b/apps/desktop/src/main/services/prs/prChatCards.test.ts @@ -227,6 +227,89 @@ describe("PR chat cards", () => { }); }); + // `total === 0` had no coverage at all, and it is the branch that rendered a + // content-free `[passing status]` chip under a green check while GitHub was + // returning 403 — the two zero-count cases are not the same fact. + it("reports a genuinely empty check set as a plain status, not as degraded", () => { + const card = buildPrCiCard({ pr: pr({ checksStatus: "passing" }), runs: [], checks: [] }); + expect(card.degradedReason).toBeUndefined(); + expect(card.actions).toBeUndefined(); + expect(card.metrics).toEqual([{ label: "status", value: "passing", tone: "success" }]); + }); + + it("says the detail is unavailable — never a false green — when the fetch failed", () => { + const card = buildPrCiCard({ + pr: pr({ checksStatus: "passing" }), + runs: [], + checks: [], + fetchError: "HTTP 403: API rate limit exceeded", + }); + expect(card.degradedReason).toContain("403"); + expect(card.metrics).toEqual([]); + expect(card.actions).toEqual([{ id: "retry", label: "Retry", kind: "primary" }]); + expect(card.fallbackText).toContain("detail unavailable"); + }); + + it("keeps a card rich when only one of the two fetches failed", () => { + const card = buildPrCiCard({ + pr: pr({ checksStatus: "passing" }), + runs: [], + checks: [check("Vercel")], + fetchError: "HTTP 403: API rate limit exceeded", + }); + expect(card.degradedReason).toBeUndefined(); + expect(card.rows?.[0]?.text).toBe("Vercel"); + }); + + it("counts the rows it dropped instead of silently capping at three", () => { + const jobs = ["a", "b", "c", "d", "e"].map((name, index) => ({ + id: index + 1, + name, + status: "completed" as const, + conclusion: "success" as const, + startedAt: null, + completedAt: null, + htmlUrl: null, + steps: [], + })); + const card = buildPrCiCard({ + pr: pr({ checksStatus: "passing" }), + runs: [run({ status: "completed", conclusion: "success", jobs })], + checks: [], + }); + expect(card.rows).toHaveLength(3); + expect(card.rowsTruncated).toBe(2); + }); + + it("surfaces the fetch failure instead of swallowing it into an empty array", async () => { + const emitAdeCard = vi.fn().mockResolvedValue(undefined); + await emitPrCardsForChange({ + change: { + pr: pr({ checksStatus: "passing" }), + previousState: "open", + previousChecksStatus: "pending", + previousReviewStatus: "requested", + previousMergeConflicts: false, + previousBehindBaseBy: 0, + }, + dataSource: { + getActionRuns: vi.fn().mockRejectedValue(new Error("HTTP 403: rate limited")), + getChecks: vi.fn().mockRejectedValue(new Error("HTTP 403: rate limited")), + getReviews: vi.fn().mockResolvedValue([]), + getReviewThreads: vi.fn().mockResolvedValue([]), + }, + chat: { + listSessions: vi.fn().mockResolvedValue([session("newer", "2026-07-27T12:00:00.000Z")]), + emitAdeCard, + }, + }); + + const ciCard = emitAdeCard.mock.calls + .map(([call]) => call.card) + .find((card) => card.variant === "pr_ci"); + expect(ciCard?.degradedReason).toContain("403"); + }); + it("bounds long review bodies before persisting them into a chat transcript", () => { const card = buildPrReviewCard({ pr: pr({ reviewStatus: "changes_requested" }), diff --git a/apps/desktop/src/main/services/prs/prChatCards.ts b/apps/desktop/src/main/services/prs/prChatCards.ts index 44dd18f71..a1b01278c 100644 --- a/apps/desktop/src/main/services/prs/prChatCards.ts +++ b/apps/desktop/src/main/services/prs/prChatCards.ts @@ -95,10 +95,14 @@ function compactCardText(value: string, maxChars = 480): string { return compact.length <= maxChars ? compact : `${compact.slice(0, maxChars - 1).trimEnd()}…`; } +/** Rows a CI card shows before it starts counting. Kept small; the rest is `+N more`. */ +const CI_ROW_CAP = 3; + function ciRows(runs: PrActionRun[], checks: PrCheck[]): { rows: AdeCardRow[]; progress: AdeCardProgress; other: number; + truncated: number; } { const jobs = runs.flatMap((run) => run.jobs); const items: Array = jobs.length > 0 ? jobs : checks; @@ -121,7 +125,8 @@ function ciRows(runs: PrActionRun[], checks: PrCheck[]): { return { progress, other: ranked.filter((entry) => entry.bucket === "skipped" || entry.bucket === "unknown").length, - rows: ranked.slice(0, 3).map(({ item, bucket }) => ({ + truncated: Math.max(0, ranked.length - CI_ROW_CAP), + rows: ranked.slice(0, CI_ROW_CAP).map(({ item, bucket }) => ({ icon: jobIcon(bucket), text: item.name, detail: bucket, @@ -151,8 +156,19 @@ export function buildPrCiCard(args: { pr: PrSummary; runs: PrActionRun[]; checks: PrCheck[]; + /** + * Why the job/check fetch produced nothing, when it failed. + * + * `[]` from a rejected GitHub call and `[]` from a PR that genuinely has no + * jobs are the same value but not the same fact: swallowing the difference + * rendered a content-free `[passing status]` chip under a green check while + * GitHub was returning 403. When this is set, the card says so and offers a + * retry instead of claiming a clean run. + */ + fetchError?: string | null; }): AdeCardPayload { const { pr, runs, checks } = args; + const fetchError = args.fetchError?.trim() || null; const latestRuns = latestRunsByWorkflow(runs); const run = newestRun(latestRuns); const attempt = Math.max(1, ...latestRuns.map((entry) => entry.runAttempt ?? 1)); @@ -160,7 +176,7 @@ export function buildPrCiCard(args: { || run?.headSha?.trim() || `${pr.githubPrNumber}:unknown-head`; const episode = `${episodeHead}:${attempt}`; - const { rows, progress, other } = ciRows(latestRuns, checks); + const { rows, progress, other, truncated } = ciRows(latestRuns, checks); const state = pr.checksStatus === "pending" ? "live" : "terminal"; const title = pr.checksStatus === "passing" ? "CI passed" @@ -173,6 +189,9 @@ export function buildPrCiCard(args: { ? "success" : "accent"; const total = progress.passed + progress.failed + progress.running + progress.queued + other; + // Only a zero-count card is actually degraded — if the jobs call failed but + // the checks call answered (or vice versa) we still have real detail to show. + const degraded = total === 0 && fetchError != null; return { cardId: `pr-ci:${pr.id}:${episode}`, @@ -187,11 +206,22 @@ export function buildPrCiCard(args: { { label: "active", value: String(progress.running + progress.queued), tone: "accent" }, ...(other > 0 ? [{ label: "other", value: String(other), tone: "neutral" as const }] : []), ] - : [{ label: "status", value: pr.checksStatus, tone }], + : degraded + ? [] + : [{ label: "status", value: pr.checksStatus, tone }], rows, progress, + ...(truncated > 0 ? { rowsTruncated: truncated } : {}), + ...(degraded + ? { + degradedReason: `Couldn’t read the job list from GitHub — ${fetchError}`, + actions: [{ id: "retry", label: "Retry", kind: "primary" as const }], + } + : {}), navTarget: prNavTarget(pr, "checks"), - fallbackText: `PR #${pr.githubPrNumber} ${title.toLowerCase()}.`, + fallbackText: degraded + ? `PR #${pr.githubPrNumber} checks are ${pr.checksStatus}; job detail unavailable (${fetchError}).` + : `PR #${pr.githubPrNumber} ${title.toLowerCase()}.`, }; } @@ -353,11 +383,20 @@ export async function emitPrCardsForChange(args: { const cards: AdeCardPayload[] = []; if (checksChanged) { - const [runs, checks] = await Promise.all([ - dataSource.getActionRuns(pr.id).catch(() => []), - dataSource.getChecks(pr.id).catch(() => []), + // Capture the failures instead of `.catch(() => [])`-ing them away: a + // rate-limited GitHub call and a PR with no jobs both produce `[]`, and + // only one of them means "everything is fine". + const [runsResult, checksResult] = await Promise.allSettled([ + dataSource.getActionRuns(pr.id), + dataSource.getChecks(pr.id), ]); - cards.push(buildPrCiCard({ pr, runs, checks })); + const runs = runsResult.status === "fulfilled" ? runsResult.value : []; + const checks = checksResult.status === "fulfilled" ? checksResult.value : []; + const fetchError = [runsResult, checksResult] + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => (result.reason instanceof Error ? result.reason.message : String(result.reason))) + .join("; ") || null; + cards.push(buildPrCiCard({ pr, runs, checks, fetchError })); } if (reviewChanged) { const [reviews, threads] = await Promise.all([ diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 5d73a68a6..0b0fee350 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -2866,6 +2866,13 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { `); db.run("create index if not exists idx_computer_use_artifacts_project_created on computer_use_artifacts(project_id, created_at)"); db.run("create index if not exists idx_computer_use_artifacts_project_kind on computer_use_artifacts(project_id, artifact_kind)"); + // Lane a capture belongs to, so deleting a lane can delete its proof instead + // of orphaning rows behind a removed worktree. Nullable and backfill-free: + // pre-existing rows keep `null` ("unknown lane") and are only reachable + // through the explicit prune/delete paths. Non-unique index only — a CRR + // table cannot carry a UNIQUE index besides its primary key. + safeAddColumn(db, "alter table computer_use_artifacts add column lane_id text"); + db.run("create index if not exists idx_computer_use_artifacts_lane on computer_use_artifacts(project_id, lane_id)"); db.run(` create table if not exists computer_use_artifact_links ( diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts index 9c754be92..1b39b5716 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts @@ -355,6 +355,44 @@ describe("storageInsightsService", () => { expect((await capped.getSnapshot()).truncated).toBe(true); }); + it("removes proof storage and drops the matching proof records with it", async () => { + // Settings used to render this card with a size and no Remove button, + // pointing the user at a proof-drawer control that did not exist. + const artifactsDir = path.join(projectRoot, ".ade", "artifacts"); + writeSized(path.join(artifactsDir, "computer-use", "shot.png"), 21); + const purged: string[] = []; + const service = createStorageInsightsService({ + projectRoot, + adeHome, + db, + logger, + purgeProofRecordsUnder: (removedPath) => purged.push(removedPath), + }); + + const targets: StorageCleanupTarget[] = [{ kind: "proof_attachments", path: artifactsDir }]; + const preview = await service.cleanupPreview(targets); + expect(preview.blocked).toEqual([]); + expect(preview.items[0]).toMatchObject({ path: artifactsDir, bytes: 21, label: "Proof and recordings" }); + + const result = await service.cleanup(targets, { preview }); + expect(result.removed).toEqual([{ path: artifactsDir, bytes: 21 }]); + expect(fs.existsSync(artifactsDir)).toBe(false); + // Rows must never outlive the bytes. + expect(purged).toEqual([artifactsDir]); + }); + + it("refuses proof cleanup for paths outside the proof and attachment stores", async () => { + const outside = path.join(projectRoot, ".ade", "cache"); + writeSized(path.join(outside, "blob.bin"), 4); + const service = createStorageInsightsService({ projectRoot, adeHome, db, logger }); + + const preview = await service.cleanupPreview([{ kind: "proof_attachments", path: outside }]); + expect(preview.items).toEqual([]); + expect(preview.blocked).toEqual([ + { path: outside, reason: "This path is not proof or attachment storage." }, + ]); + }); + it("itemizes a removed target and a target deleted after preview", async () => { const first = fs.mkdtempSync(path.join(os.tmpdir(), "ade-storage-old-first-")); const second = fs.mkdtempSync(path.join(os.tmpdir(), "ade-storage-old-second-")); diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.ts b/apps/desktop/src/main/services/storage/storageInsightsService.ts index f61edcaf2..9be7e54b9 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.ts @@ -141,6 +141,13 @@ export type StorageInsightsServiceOptions = { laneService?: LaneLifecycleBackend | null; projectConfigService?: LaneCleanupConfigReader | null; releaseLaneRuntimeResources?: ((laneId: string) => void | Promise) | null; + /** + * Drops the proof records whose stored file lived at or under a removed path. + * Injected rather than imported so this service keeps its single dependency + * on the filesystem; absent in the daemon-backed fallback instance, where the + * broker lives in the other process and prunes on its own next read. + */ + purgeProofRecordsUnder?: (removedPath: string) => void; }; export function isObsoleteRecoveryBackup( @@ -1016,6 +1023,16 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti } if (!stat.isFile()) return { valid: null, reason: "Recovery backups must be files." }; label = "Recovery backup"; + } else if (target.kind === "proof_attachments") { + if (await hasSymlinkAncestor(projectRoot, targetPath)) { + return { valid: null, reason: "Links cannot be used in a cleanup path." }; + } + // Same jail the broker and the `ade-artifact://` handler enforce. + const proofRoots = [layout.artifactsDir, path.join(layout.adeDir, "attachments")]; + if (!proofRoots.some((root) => isSameOrWithin(root, targetPath))) { + return { valid: null, reason: "This path is not proof or attachment storage." }; + } + label = isSameOrWithin(layout.artifactsDir, targetPath) ? "Proof and recordings" : "Attachments"; } else { return { valid: null, reason: "This cleanup target is not supported." }; } @@ -1123,6 +1140,18 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti } else { await fs.promises.rm(checked.valid.path, { recursive: true, force: false }); } + if (target.kind === "proof_attachments") { + // Rows must not outlive the bytes: a proof record whose file is gone + // renders as a permanently broken tile in the drawer. + try { + options.purgeProofRecordsUnder?.(checked.valid.path); + } catch (error) { + options.logger.warn("storage.cleanup_proof_records_failed", { + path: checked.valid.path, + error: error instanceof Error ? error.message : String(error), + }); + } + } removed.push({ path: checked.valid.path, bytes: checked.valid.bytes }); } catch (error) { failed.push({ path: rawPath, reason: error instanceof Error ? error.message : String(error) }); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 6cb55e03e..d809f8361 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -570,9 +570,11 @@ import type { RunHealthCheckArgs, ActivateFallbackArgs, DeactivateFallbackArgs, + ComputerUseArtifactBrokenRecord, + ComputerUseArtifactDeleteArgs, + ComputerUseArtifactDeleteResult, ComputerUseArtifactListArgs, ComputerUseArtifactReviewArgs, - ComputerUseArtifactRouteArgs, ComputerUseArtifactView, ComputerUseEventPayload, ComputerUseOwnerSnapshot, @@ -1788,9 +1790,16 @@ declare global { getOwnerSnapshot: ( args: ComputerUseOwnerSnapshotArgs, ) => Promise; - routeArtifact: ( - args: ComputerUseArtifactRouteArgs, - ) => Promise; + deleteArtifacts: ( + args: ComputerUseArtifactDeleteArgs, + ) => Promise; + listBrokenArtifacts: (args?: { + limit?: number; + }) => Promise; + pruneBrokenArtifacts: () => Promise; + recoverArtifact: (args: { + artifactId: string; + }) => Promise; updateArtifactReview: ( args: ComputerUseArtifactReviewArgs, ) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 90fe17394..37281f6dc 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -611,7 +611,9 @@ import type { DeactivateFallbackArgs, ComputerUseArtifactListArgs, ComputerUseArtifactReviewArgs, - ComputerUseArtifactRouteArgs, + ComputerUseArtifactBrokenRecord, + ComputerUseArtifactDeleteArgs, + ComputerUseArtifactDeleteResult, ComputerUseArtifactView, ComputerUseEventPayload, ComputerUseOwnerSnapshot, @@ -6612,17 +6614,50 @@ contextBridge.exposeInMainWorld("ade", { args: ComputerUseOwnerSnapshotArgs, ): Promise => computerUseOwnerSnapshotCache.get(serializeIpcCacheArgs(args)), - routeArtifact: async ( - args: ComputerUseArtifactRouteArgs, + deleteArtifacts: async ( + args: ComputerUseArtifactDeleteArgs, + ): Promise => + clearAround( + () => computerUseOwnerSnapshotCache.clear(), + () => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "deleteArtifacts", + { args }, + () => ipcRenderer.invoke(IPC.computerUseDeleteArtifacts, args), + ), + ), + listBrokenArtifacts: async ( + args: { limit?: number } = {}, + ): Promise => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "listBrokenArtifacts", + { args }, + () => ipcRenderer.invoke(IPC.computerUseListBrokenArtifacts, args), + ), + pruneBrokenArtifacts: async (): Promise => + clearAround( + () => computerUseOwnerSnapshotCache.clear(), + () => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "pruneBrokenArtifacts", + { args: {} }, + () => ipcRenderer.invoke(IPC.computerUsePruneBrokenArtifacts), + ), + ), + recoverArtifact: async ( + args: { artifactId: string }, ): Promise => clearAround( () => computerUseOwnerSnapshotCache.clear(), () => callProjectRuntimeActionOr( "computer_use_artifacts", - "routeArtifact", + "recoverArtifact", { args }, - () => ipcRenderer.invoke(IPC.computerUseRouteArtifact, args), + () => ipcRenderer.invoke(IPC.computerUseRecoverArtifact, args), ), ), updateArtifactReview: async ( diff --git a/apps/desktop/src/renderer/components/chat/AdeCard.tsx b/apps/desktop/src/renderer/components/chat/AdeCard.tsx index 46ce938a6..cda36fe2c 100644 --- a/apps/desktop/src/renderer/components/chat/AdeCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AdeCard.tsx @@ -2,19 +2,30 @@ import { useEffect, useState } from "react"; import { ArrowSquareOut, CaretRight, - CheckCircle, - Circle, - DotsThree, - File as FileIcon, - Info, - SpinnerGap, - XCircle, + Cube, + GitMerge, + Warning, + type Icon as PhosphorIcon, } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; import { formatSubagentDurationMs } from "../../lib/format"; import { navigateToAppTarget } from "../../lib/openExternal"; import { CHAT_TRANSCRIPT_GLASS_CARD_CLASS } from "./chatTranscriptChrome"; -import { chatChipToneClass } from "./chatSurfaceTheme"; +import { + CHAT_CARD_MICRO_TEXT, + CHAT_CARD_WIDTH_CLASS, + ChatCard, + ChatCardButton, + ChatCardChip, + ChatCardDetail, + ChatCardDetailRow, + ChatCardDiffStat, + ChatCardMeter, + ChatCardRow, + ChatCardSub, + ChatCardTitle, + type ChatCardTone, +} from "./chatCardPrimitives"; import { adeCardDeeplink, adeCardFallbackText, @@ -23,68 +34,86 @@ import { normalizeAdeCardTone, type AdeCardIcon, type AdeCardPayload, - type AdeCardTone, } from "../../../shared/adeCard"; -import type { ChatSurfaceChipTone } from "../../../shared/types"; /** * The `ade_card` renderer. * - * Structurally and stylistically a sibling of `SubagentActivityCards.tsx` — same - * `max-w-[min(100%,70ch)]` shell, same `calc(var(--chat-radius-card)-6px)` radius, - * same `--chat-font-size`-relative type scale, same one-line ` · `-joined status, - * same whole-card-clickable-with-identical-inner-markup pattern. It also inherits - * that file's tone policy: THERE IS NO RED HERE. A failed thing is amber, and the - * detail lives in the row's `detail` column rather than a red error block. + * Built entirely from `./chatCardPrimitives` — the `[glyph | content | meta]` + * grid, `CHAT_CARD_WIDTH_CLASS`, and the shared tone vocabulary — so an + * `ade_card` lines up column-for-column with every other transcript row rather + * than inventing its own indent and its own width. + * + * Shape follows the state, not the variant name: + * + * - a green/quiet result is ONE line (no box, hairline rule); + * - a failure gets an amber rail and lists only the rows that failed; + * - a live card gets an inset box and a progress meter. * - * A variant this build does not recognize renders `fallbackText` + the deeplink - * instead of nothing — that degradation is the reason one wire contract can ship - * across three independent release trains. + * Tone policy inherited from `shared/adeCard.ts`: THERE IS NO RED HERE. A failed + * thing is amber, and its detail lives in the row's detail column rather than a + * red error block. A variant this build does not recognize renders + * `fallbackText` + the deeplink instead of nothing — that degradation is the + * reason one wire contract can ship across three independent release trains. */ -const TONE_TEXT_CLASS: Record = { - neutral: "text-fg/45", - accent: "text-[color:var(--chat-accent)]", - success: "text-emerald-300/80", - warning: "text-amber-200/85", -}; - -const TONE_CHIP_TONE: Record = { - neutral: "muted", - accent: "accent", - success: "success", - warning: "warning", -}; - -/** Progress-bar segment fills. Failure is amber, per the house policy above. */ -const PROGRESS_SEGMENT_CLASS: Record<"passed" | "failed" | "running" | "queued", string> = { - passed: "bg-emerald-400/70", - failed: "bg-amber-400/80", - running: "bg-[color:var(--chat-accent)]", - queued: "bg-white/12", -}; - -function RowGlyph({ icon, tone }: { icon: AdeCardIcon | undefined; tone: AdeCardTone }) { - const className = cn("shrink-0", TONE_TEXT_CLASS[tone]); +/** Semantic row glyph → the shared tone vocabulary. */ +function rowToneForIcon(icon: AdeCardIcon | undefined, declared: ChatCardTone): ChatCardTone { switch (icon) { case "pass": - return ; + return "ok"; case "fail": - return ; + return "warn"; case "running": - return ; - case "queued": - return ; + return "running"; case "skipped": - return ; + return "idle"; + case "queued": case "file": - return ; case "info": default: - return ; + return declared; + } +} + +function toneOf(value: string | null | undefined): ChatCardTone { + switch (normalizeAdeCardTone(value)) { + case "success": + return "ok"; + case "warning": + return "warn"; + case "accent": + return "running"; + default: + return "neutral"; } } +/** Head glyph per variant family. Everything else falls back to the tone glyph. */ +function variantIcon(variant: string): PhosphorIcon | undefined { + if (variant === "pr_merged" || variant === "pr_merge_ready") return GitMerge; + if (variant === "pr_conflict") return Warning; + if (variant === "proof_artifact") return Cube; + return undefined; +} + +/** + * `+23,574 / −912` when the emitter shipped additions/deletions metrics, so a PR + * card can be one line with the diff in the meta column instead of two chips. + */ +function diffStatFromMetrics(card: AdeCardPayload): { additions: number; deletions: number } | null { + const read = (label: string): number | null => { + const metric = (card.metrics ?? []).find((entry) => entry.label === label); + if (!metric) return null; + const parsed = Number.parseInt(metric.value.replace(/[^0-9]/g, ""), 10); + return Number.isFinite(parsed) ? parsed : null; + }; + const additions = read("additions"); + const deletions = read("deletions"); + if (additions == null && deletions == null) return null; + return { additions: additions ?? 0, deletions: deletions ?? 0 }; +} + function liveElapsedText(startedAt: string | null | undefined, nowMs: number): string | null { if (!startedAt) return null; const start = Date.parse(startedAt); @@ -92,6 +121,20 @@ function liveElapsedText(startedAt: string | null | undefined, nowMs: number): s return formatSubagentDurationMs(Math.max(0, nowMs - start)); } +/** + * The span between the card's first and last emit. NOT a run duration — a CI + * card that is re-polled for three hours has a three-hour span and a three- + * minute run. Labelled `tracked` wherever it appears so the two never read as + * the same number. + */ +function trackedSpanText(card: AdeCardPayload): string | null { + if (!card.createdAt || !card.updatedAt) return null; + const span = Date.parse(card.updatedAt) - Date.parse(card.createdAt); + if (!Number.isFinite(span) || span < 60_000) return null; + const text = formatSubagentDurationMs(span); + return text ? `tracked ${text}` : null; +} + export function AdeCard({ card, onAction, @@ -114,39 +157,6 @@ export function AdeCard({ const known = isKnownAdeCardVariant(card.variant); const deeplink = adeCardDeeplink(card.navTarget); const navigable = Boolean(card.navTarget); - - const elapsed = isLive - ? liveElapsedText(card.createdAt, nowMs) - : formatSubagentDurationMs( - card.createdAt && card.updatedAt - ? Math.max(0, Date.parse(card.updatedAt) - Date.parse(card.createdAt)) - : null, - ); - - const metrics = card.metrics ?? []; - const rows = card.rows ?? []; - const actions = (card.actions ?? []).filter((action) => ( - (action.id === "open" && navigable) || onAction != null - )); - const progress = card.progress ?? null; - const progressTotal = adeCardProgressTotal(progress); - const hasWarning = metrics.some((metric) => normalizeAdeCardTone(metric.tone) === "warning") - || rows.some((row) => normalizeAdeCardTone(row.tone) === "warning") - || (progress?.failed ?? 0) > 0; - - const cardShell = cn( - "w-full max-w-[min(100%,70ch)] overflow-hidden rounded-[calc(var(--chat-radius-card)-6px)] border transition-colors", - hasWarning - ? "border-amber-400/16 bg-amber-400/[0.05] hover:border-amber-300/26" - : "border-[color:color-mix(in_srgb,var(--chat-accent)_16%,transparent)] bg-[color:color-mix(in_srgb,var(--chat-accent)_6%,transparent)] hover:border-[color:color-mix(in_srgb,var(--chat-accent)_28%,transparent)]", - ); - - const statusParts = [ - isLive ? "running" : "done", - card.subtitle?.trim() || null, - elapsed, - ].filter((part): part is string => Boolean(part)); - const openCard = () => { if (card.navTarget) navigateToAppTarget(card.navTarget); }; @@ -155,7 +165,7 @@ export function AdeCard({ if (!known) { const text = adeCardFallbackText(card); return ( -
+
{text}
@@ -174,132 +184,121 @@ export function AdeCard({ ); } - // Built ONCE and rendered byte-identically whether or not the card navigates — - // the navigable/passive split must never change the layout it wraps. + const metrics = card.metrics ?? []; + const rows = card.rows ?? []; + const progress = card.progress ?? null; + const progressTotal = adeCardProgressTotal(progress); + const degradedReason = card.degradedReason?.trim() || null; + const failedCount = Math.max(0, progress?.failed ?? 0); + const warnRows = rows.filter((row) => normalizeAdeCardTone(row.tone) === "warning" || row.icon === "fail"); + const hasWarning = failedCount > 0 + || warnRows.length > 0 + || metrics.some((metric) => normalizeAdeCardTone(metric.tone) === "warning" && metric.value !== "0"); + + // The two clocks are deliberately different numbers with different labels. + const realDuration = formatSubagentDurationMs(card.durationMs ?? null); + const elapsed = isLive ? liveElapsedText(card.createdAt, nowMs) : null; + const tracked = !isLive && !realDuration ? trackedSpanText(card) : null; + + const headTone: ChatCardTone = isLive ? "running" : hasWarning ? "warn" : degradedReason ? "neutral" : "ok"; + const diff = diffStatFromMetrics(card); + + // A card only earns a box when it has something to put in one. A passing + // result collapses to a single hairline row. + const detailRows = hasWarning ? warnRows : rows; + const showDetail = detailRows.length > 0 && (hasWarning || isLive || card.variant === "proof_artifact"); + const skin = hasWarning ? "rail" : showDetail || isLive || degradedReason ? "inset" : "line"; + + const metaParts = [ + hasWarning && progress ? `${progress.passed} passed` : null, + !hasWarning && !isLive && progressTotal > 0 ? `${progressTotal} job${progressTotal === 1 ? "" : "s"}` : null, + realDuration ? `ran ${realDuration}` : null, + elapsed, + tracked, + ].filter((part): part is string => Boolean(part)); + + const actions = (card.actions ?? []).filter((action) => ( + (action.id === "open" && navigable) || onAction != null + )); + const inner = ( <> - {progress && progressTotal > 0 ? ( -
- {(["passed", "failed", "running", "queued"] as const).map((bucket) => { - const value = Math.max(0, progress[bucket]); - if (value <= 0) return null; - return ( - - ); - })} + : metaParts.join(" · ")} + action={navigable && !actions.length ? ( + // A span, not a button: the whole card already navigates, and nesting + // a second button here would make the card ambiguous to assistive + // tech (and to `getByRole("button")`). + + open + + + ) : null} + > +
+ {card.title} + {card.stale ? stale : null} + {degradedReason ? detail unavailable : null}
- ) : null} + {card.subtitle?.trim() ? {card.subtitle.trim()} : null} + {degradedReason ? {degradedReason} : null} +
-
- - {isLive ? ( - - ) : hasWarning ? ( - - ) : ( - - )} - - -
-
- - {card.title} - - {navigable ? ( - - open - - - ) : null} -
+ {/* A meter only says something while work is still in flight. */} + {isLive && progress && progressTotal > 0 ? ( + + ) : null} - {statusParts.length ? ( -
- {statusParts.join(" · ")} -
- ) : null} + {/* Chips are for counts the head line cannot carry (a diff already did). */} + {!diff && metrics.length && (hasWarning || isLive || card.variant === "proof_artifact") ? ( +
+ {metrics.map((metric) => ( + + {metric.value} {metric.label} + + ))} +
+ ) : null} - {metrics.length ? ( -
- {metrics.map((metric) => { - const tone = normalizeAdeCardTone(metric.tone); - return ( - - {metric.value} - {metric.label} - - ); - })} + {showDetail ? ( + + {detailRows.map((row, index) => ( + + ))} + {card.rowsTruncated && card.rowsTruncated > 0 ? ( +
+ +{card.rowsTruncated} more
) : null} - - {rows.length ? ( -
    - {rows.map((row, index) => { - const tone = normalizeAdeCardTone(row.tone); - return ( -
  • - - {row.text} - {row.detail?.trim() ? ( - - {row.detail} - - ) : null} -
  • - ); - })} -
- ) : null} -
-
+ + ) : null} {actions.length ? ( -
+
{actions.map((action) => ( - + ))}
) : null} @@ -311,7 +310,9 @@ export function AdeCard({ // buttons, and a button inside a button is invalid markup that React and // the browser both mishandle. return ( -
{inner} -
+ ); } - return
{inner}
; + return {inner}; } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 84dda98cc..c2027d0d9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -11,6 +11,10 @@ import type { ComputerUseArtifactView, } from "../../../shared/types"; import * as modelRegistry from "../../../shared/modelRegistry"; +import { ADE_NAVIGATE_TARGET_EVENT } from "../../lib/openExternal"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; vi.mock("lottie-react", () => ({ useLottie: () => ({ @@ -449,12 +453,35 @@ describe("AgentChatMessageList operator navigation suggestions", () => { }); describe("AgentChatMessageList transcript rendering", () => { - it("renders collected proof even before transcript rows arrive", () => { - renderMessageList([], { proofArtifacts: [transcriptProofArtifact] }); + // Proof used to be appended after every row as a permanent thread footer, so + // it never appeared where the capture happened. It is now an inline + // chronological `ade_card` row plus a chip on the turn rule; the footer is + // gone from BOTH render paths. + it("no longer pins collected proof to the bottom of the thread", () => { + const rendered = renderMessageList([], { proofArtifacts: [transcriptProofArtifact] }); - expect(screen.getByText("Proof collected in this chat")).toBeTruthy(); - expect(screen.getByText("Focused tests passed")).toBeTruthy(); - expect(screen.getByText("381 focused tests passed.")).toBeTruthy(); + expect(screen.queryByText("Proof collected in this chat")).toBeNull(); + expect(rendered.container.querySelector("[data-chat-proof-timeline]")).toBeNull(); + }); + + it("chips proof onto the turn rule of the turn that captured it", () => { + renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "user_message", text: "Capture proof.", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:02:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }, + ], + { proofArtifacts: [{ ...transcriptProofArtifact, createdAt: "2026-03-17T10:01:00.000Z" }] }, + ); + + expect(screen.getByRole("button", { name: /1 proof/ })).toBeTruthy(); }); it("keeps turn file-change summaries visible without a session id", () => { @@ -1134,7 +1161,7 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.getByText("interrupted")).toBeTruthy(); }); - it("labels end-of-turn wall time as ran for, not worked for", () => { + it("labels end-of-turn wall time as ran, not worked for", () => { renderMessageList([ { sessionId: "session-1", @@ -1148,7 +1175,8 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(screen.getByText("Ran for 2m")).toBeTruthy(); + // The turn rule reads `10:04 · ran 3m 32s` — mono, tabular, lower case. + expect(screen.getByText("ran 2m")).toBeTruthy(); expect(screen.queryByText(/Worked for/)).toBeNull(); }); @@ -2950,12 +2978,55 @@ describe("AgentChatMessageList transcript rendering", () => { expect(rendered.container.textContent).toContain("The focused tests pass."); expect(rendered.container.textContent).toContain("1 file changed"); expect(rendered.container.textContent).not.toContain("npm test"); - expect(rendered.container.textContent).toContain("Ran for 5.0s"); + expect(rendered.container.textContent).toContain("ran 5.0s"); fireEvent.click(screen.getByRole("button", { name: "Show activity from this turn" })); expect(rendered.container.textContent).toContain("npm test"); }); + // "Keep the last": the row you read is the most recent one; quiet successes + // fold behind a count. Failures never fold — burying a rejected command + // behind "+N previous" is exactly the bug this shape must not introduce. + it("keeps the last tool call and folds the quiet successes behind a count", () => { + const command = (index: number, status: "completed" | "failed") => ({ + sessionId: "session-1", + timestamp: `2026-03-17T10:00:0${index}.000Z`, + event: { + type: "command" as const, + command: `step-${index}.sh`, + cwd: "/repo", + output: "ok", + itemId: `command-${index}`, + turnId: "turn-1", + status, + exitCode: status === "completed" ? 0 : 1, + }, + }); + + const rendered = renderMessageList([ + command(1, "completed"), + command(2, "completed"), + command(3, "failed"), + command(4, "completed"), + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:06.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }, + ]); + + fireEvent.click(screen.getByRole("button", { name: "Show activity from this turn" })); + + // Last call + the failure survive; the two quiet successes fold. + expect(rendered.container.textContent).toContain("step-4.sh"); + expect(rendered.container.textContent).toContain("step-3.sh"); + expect(rendered.container.textContent).not.toContain("step-1.sh"); + expect(rendered.container.textContent).toContain("+2 previous tool calls"); + + fireEvent.click(screen.getByText("+2 previous tool calls")); + expect(rendered.container.textContent).toContain("step-1.sh"); + }); + it("keeps mixed provider turn ids together while resetting fallback activity at a new user turn", () => { const rendered = renderMessageList(mixedIdToolActivityBoundaryEvents()); @@ -3132,7 +3203,7 @@ describe("AgentChatMessageList transcript rendering", () => { fireEvent.click(screen.getByRole("button", { name: "Show activity from the active turn" })); expect(rendered.container.textContent).toContain("pwd"); expect(rendered.container.textContent).toContain("shell"); - expect(rendered.container.innerHTML).toContain("max-w-[min(100%,70ch)]"); + expect(rendered.container.innerHTML).toContain("max-w-[var(--chat-content-width,52rem)]"); }); it("renders each subagent as spawn + result cards (no per-tick activity bundle)", () => { @@ -4249,13 +4320,99 @@ describe("AgentChatMessageList ade_card dispatch", () => { expect(screen.queryByText("CI failed")).toBeNull(); }); - it("does not advertise a host action without a real card-action dispatcher", () => { - const onApproval = vi.fn(); - renderMessageList( - [cardEnvelope({ actions: [{ id: "open-lane", label: "Open lane", kind: "primary" }] })], - { onApproval }, - ); - expect(screen.queryByText("Open lane")).toBeNull(); - expect(onApproval).not.toHaveBeenCalled(); + // Previously the transcript passed no `onAction`, and `` filters out + // every action it cannot route — so the schema's action row was unreachable + // by construction. It is now dispatched. + it("renders a host action and broadcasts it as ade:chat:card-action", () => { + const listener = vi.fn(); + window.addEventListener("ade:chat:card-action", listener); + try { + renderMessageList([ + cardEnvelope({ actions: [{ id: "open-lane", label: "Open lane", kind: "primary" }] }), + ]); + fireEvent.click(screen.getByText("Open lane")); + expect(listener).toHaveBeenCalledTimes(1); + const detail = (listener.mock.calls[0]![0] as CustomEvent).detail; + expect(detail).toMatchObject({ actionId: "open-lane", cardId: "run-42", variant: "proof_artifact" }); + } finally { + window.removeEventListener("ade:chat:card-action", listener); + } + }); + + it("routes retry back through the card's own surface rather than a dead broadcast", () => { + const navListener = vi.fn(); + window.addEventListener(ADE_NAVIGATE_TARGET_EVENT, navListener); + try { + renderMessageList([ + cardEnvelope({ + variant: "pr_ci", + title: "CI is running", + degradedReason: "Couldn’t read the job list from GitHub — 403", + navTarget: { kind: "pr", repoOwner: "arul28", repoName: "ADE", prNumber: 916 }, + actions: [{ id: "retry", label: "Retry", kind: "primary" }], + }), + ]); + fireEvent.click(screen.getByText("Retry")); + expect(navListener).toHaveBeenCalled(); + } finally { + window.removeEventListener(ADE_NAVIGATE_TARGET_EVENT, navListener); + } + }); + + it("says the detail is unavailable instead of showing a content-free green card", () => { + renderMessageList([ + cardEnvelope({ + variant: "pr_ci", + title: "CI passed", + metrics: [], + degradedReason: "Couldn’t read the job list from GitHub — 403", + }), + ]); + expect(screen.getByText("detail unavailable")).toBeTruthy(); + expect(screen.getByText(/403/)).toBeTruthy(); + }); +}); + +/** + * The transcript's ONE content width. + * + * Before `--chat-content-width` there were seven disagreeing clamps in this + * directory, and the worst offender resolved `70` characters against the + * browser's 16px default (no card sets a font-size), so every card stopped + * ~26% short of the prose above it. This guard is source-level on purpose: a + * jsdom render cannot catch a clamp on a code path that happens not to be + * exercised. + */ +describe("chat transcript content width", () => { + const chatDir = path.dirname(fileURLToPath(import.meta.url)); + + /** Components only — a test file may name the old clamp to explain it. */ + function chatComponentFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return chatComponentFiles(full); + if (/\.test\.tsx?$/.test(entry.name)) return []; + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); + } + + it("has no `ch`-relative card clamp left anywhere under components/chat", () => { + const offenders = chatComponentFiles(chatDir) + .filter((file) => fs.readFileSync(file, "utf8").includes("70ch")) + .map((file) => path.basename(file)); + expect(offenders).toEqual([]); + }); + + it("routes every transcript-row max-width through the shared token", () => { + // `max-w-[min(100%, …)]` is the row-level idiom the redesign unified. A + // bare `max-w-[22rem]` on a nested control is a different thing and stays. + const offenders: string[] = []; + for (const file of chatComponentFiles(chatDir)) { + const source = fs.readFileSync(file, "utf8"); + for (const match of source.matchAll(/max-w-\[min\(100%,\s*[^\]]*\)\]/g)) { + offenders.push(`${path.basename(file)}: ${match[0]}`); + } + } + expect(offenders).toEqual([]); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 8c7209c78..b68128dec 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -32,6 +32,8 @@ import { Code, Paperclip, Target, + Clock, + Cube, } from "@phosphor-icons/react"; import type { AgentChatApprovalDecision, @@ -55,7 +57,7 @@ import type { import { getModelById, resolveModelDescriptor, type ModelDescriptor } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { formatTime } from "../../lib/format"; -import { openUrlInAdeBrowser } from "../../lib/openExternal"; +import { navigateToAppTarget, openUrlInAdeBrowser } from "../../lib/openExternal"; import { isPathEqualOrDescendant, isWindowsAbsolutePath, normalizePath } from "../../lib/pathUtils"; import { describeToolIdentifier, replaceInternalToolNames } from "./toolPresentation"; import { chatChipToneClass } from "./chatSurfaceTheme"; @@ -134,7 +136,13 @@ import { ContextCompactDivider } from "./ContextCompactDivider"; import { terminalReasonLabel, formatTimedOutAfter, formatGrepTotalsPrefix } from "./chatEventDisplay"; import { peekPendingSessionAnchor, takePendingSessionAnchor } from "../terminals/pendingSessionAnchors"; import { ChatTurnFileChangesPanel, aggregateFiles } from "./ChatFileChangesPanel"; -import { ChatProofTimeline } from "./ChatComputerUsePanel"; +import { + ChatCardRow, + ChatCardSub, + ChatCardTitle, + formatScheduledRunAt, + type ChatCardTone, +} from "./chatCardPrimitives"; /** * Threaded into MarkdownBlock only for Claude-family sessions. When present, a @@ -235,7 +243,7 @@ function CodexTurnRecoveryCard({ }, [event.turnId, onRecover, pendingAction, resultLabels, targetSessionId]); return ( -
+
recovery @@ -328,7 +336,7 @@ function CodexTurnRecoveryReceipt({ event }: { event: CodexTurnRecoveryEvent }) ? "Recovery failed" : "Recovering"; return ( -
+
{event.state === "recovered" ? : } @@ -1370,13 +1378,22 @@ function activityBundleDetail(item: ChatActivityBundleItem): string | null { const changed = event.items.filter((task) => task.status !== "pending"); return changed.slice(0, 3).map((task) => `${task.status.replace("_", " ")}: ${task.description}`).join(" · ") || null; } + // `nextRunAt` deliberately does NOT fall through to here — a raw ISO string + // (`2026-07-28T12:17:18.016Z`) is not a brief. It renders formatted in the + // row's meta column instead; see `activityBundleWhen`. return event.summary?.trim() || event.error?.trim() || event.cron?.trim() - || event.nextRunAt?.trim() || null; } +/** `runs in 4m · 12:17` for a scheduled row, or null for anything else. */ +function activityBundleWhen(item: ChatActivityBundleItem): string | null { + const event = item.event; + if (event.type !== "scheduled_work_update") return null; + return formatScheduledRunAt(event.nextRunAt); +} + function activityKindLabel(kind: ReturnType): string { if (kind === "task") return "tasks"; return "schedule"; @@ -1387,19 +1404,20 @@ function activityKindTone(kind: ReturnType): string { return "border-amber-300/14 bg-amber-300/[0.055] text-amber-100/72"; } -function activityStatusIcon(item: ChatActivityBundleItem): React.ReactNode { +/** + * Status → the shared card tone. Replaces a bespoke glyph switch that painted + * failures `text-red-300/80` — failures are amber in ADE chat, never red. + */ +function activityBundleTone(item: ChatActivityBundleItem): ChatCardTone { if (item.event.type === "todo_update") { const total = item.event.items.length; const completed = item.event.items.filter((task) => task.status === "completed").length; - return total > 0 && completed === total - ? - : ; + return total > 0 && completed === total ? "ok" : "running"; } const status = activityBundleStatus(item); - if (status.includes("failed")) return ; - if (status.includes("stopped") || status.includes("cancelled")) return ; - if (status.includes("complete")) return ; - return ; + if (status.includes("failed") || status.includes("stopped") || status.includes("cancelled")) return "warn"; + if (status.includes("complete")) return "ok"; + return "running"; } function openChatInfoFromActivity(sessionId: string | null | undefined, taskId: string | null): void { @@ -1468,32 +1486,37 @@ function ActivityBundleRow({ const detail = activityBundleDetail(item); const title = activityBundleTitle(item); const taskId = activityBundleTaskId(item); + // Scheduled work reads as "when, then what" — the clock belongs in the meta + // column with everything else's timing, and the brief sits under the title. + const when = activityBundleWhen(item); return ( ); } @@ -3203,7 +3226,7 @@ function QueueRecoveryCard({ if (settled || expired) return null; return ( -
+
Cleared {messageCount} queued message{messageCount === 1 ? "" : "s"}. @@ -3232,6 +3255,45 @@ function QueueRecoveryCard({ ); } +/** + * Dispatcher for an `ade_card`'s non-`open` actions. + * + * `` filters out every action it cannot route, so before this existed + * the schema's action row was unreachable: no `onAction` prop meant no buttons, + * whatever the emitter sent. Two behaviours: + * + * - `retry` / `refresh` re-enter the card's own surface when it has a nav + * target. That is a real retry, not a no-op: the PR checks tab refetches on + * mount, which is exactly what a rate-limited CI card needs. + * - anything else is broadcast as `ade:chat:card-action` for a host to pick up, + * the same extension shape as `ade:chat:open-info`. + */ +function dispatchAdeCardAction( + card: Extract, + actionId: string, + sessionId: string | null, +): void { + if ((actionId === "retry" || actionId === "refresh") && card.navTarget) { + navigateToAppTarget(card.navTarget); + return; + } + try { + window.dispatchEvent( + new CustomEvent("ade:chat:card-action", { + detail: { + actionId, + cardId: card.cardId, + variant: card.variant, + ...(sessionId ? { sessionId } : {}), + ...(card.navTarget ? { navTarget: card.navTarget } : {}), + }, + }), + ); + } catch { + /* no-op */ + } +} + function renderEvent( envelope: RenderEnvelope, options?: { @@ -3304,7 +3366,7 @@ function renderEvent( ) : content} + {proofCount > 0 ? ( + + ) : null}
@@ -4903,7 +4994,9 @@ function DoneTurnDivider({ animate={{ opacity: 1, height: "auto", y: 0 }} exit={{ opacity: 0, height: 0, y: -4 }} transition={{ duration: 0.16, ease: "easeOut" }} - className="mx-auto mt-2 w-full max-w-[min(100%,70ch)] overflow-hidden border-l border-white/[0.08] pl-4" + /* Left-aligned like every other transcript row — this used to be the + only `mx-auto`-centred block in the thread. */ + className="mt-2 w-full max-w-[var(--chat-content-width,52rem)] overflow-hidden border-l border-white/[0.08] pl-4" > void; onRestoreCancelledQueue?: (recoveryId: string) => Promise; settledQueueRecoveryIds?: Set; + /** Proof captured during this turn — surfaced as a chip on the turn rule. */ + turnProofCount?: number; + onOpenProofDrawer?: () => void; }; const EventRow = React.memo(function EventRow({ @@ -5143,6 +5239,8 @@ const EventRow = React.memo(function EventRow({ onCancelQueuedMessage, onRestoreCancelledQueue, settledQueueRecoveryIds, + turnProofCount = 0, + onOpenProofDrawer, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) && !sessionEnded @@ -5182,7 +5280,7 @@ const EventRow = React.memo(function EventRow({ ) : null} {envelope.event.type === "work_log_group" ? ( -
+
{ + const map = new Map(); + if (!proofArtifacts.length) return map; + const stamps = proofArtifacts + .map((artifact) => Date.parse(artifact.createdAt)) + .filter((value) => Number.isFinite(value)) + .sort((left, right) => left - right); + if (!stamps.length) return map; + let windowStart = Number.NEGATIVE_INFINITY; + for (const env of allGroupedRows) { + if (env.event.type !== "done") continue; + const endMs = Date.parse(env.timestamp); + if (!Number.isFinite(endMs)) continue; + const count = stamps.filter((stamp) => stamp > windowStart && stamp <= endMs).length; + if (count > 0) map.set(env.key, count); + windowStart = endMs; + } + return map; + }, [allGroupedRows, proofArtifacts]); + const handleReviewChanges = useCallback(() => { if (!turnSummary?.changedFileCount) return; const state = currentLaneId ? { laneId: currentLaneId } : undefined; @@ -6756,6 +6886,9 @@ function AgentChatMessageListMain({ const turnToolEntries = envelope.event.type === "done" ? (transcriptToolActivity.byDoneRowKey.get(envelope.key) ?? []) : undefined; + const turnProofCount = envelope.event.type === "done" + ? (turnProofCountByRowKey.get(envelope.key) ?? 0) + : undefined; const turnModel = currentTurn ? (turnModelState.map.get(currentTurn) ?? null) : turnModelState.lastModel; @@ -6779,6 +6912,8 @@ function AgentChatMessageListMain({ turnModel={turnModel} turnEndDurationMs={turnEndDurationMs} turnToolEntries={turnToolEntries} + turnProofCount={turnProofCount} + onOpenProofDrawer={onOpenProofDrawer} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} onRecoverContinuity={onRecoverContinuity} @@ -6829,6 +6964,8 @@ function AgentChatMessageListMain({ turnModel={turnModel} turnEndDurationMs={turnEndDurationMs} turnToolEntries={turnToolEntries} + turnProofCount={turnProofCount} + onOpenProofDrawer={onOpenProofDrawer} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} onRecoverContinuity={onRecoverContinuity} @@ -6867,7 +7004,7 @@ function AgentChatMessageListMain({ settledQueueRecoveryIds={settledQueueRecoveryIds} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey]); + }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey, turnProofCountByRowKey, onOpenProofDrawer]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { @@ -6885,7 +7022,7 @@ function AgentChatMessageListMain({ const streamingIndicator = showStreamingIndicator && !sessionEnded ? ( ) : null} - {rows.length === 0 && !streamingIndicator && proofArtifacts.length === 0 ? ( + {/* Proof is no longer a thread footer: it renders inline at the point + of capture as an `ade_card` row, so an empty transcript is empty + even when the chat owns artifacts. */} + {rows.length === 0 && !streamingIndicator ? ( null ) : shouldVirtualize ? ( /* ── Virtualized path: only render rows in / near the viewport ── */ @@ -6997,11 +7137,6 @@ function AgentChatMessageListMain({
{streamingIndicator} {turnDivider} -
) : ( /* ── Non-virtualized path: render all rows (small conversation) ── */ @@ -7009,11 +7144,6 @@ function AgentChatMessageListMain({ {groupedRows.map((envelope, index) => renderRow(envelope, index, false))} {streamingIndicator} {turnDivider} -
)}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 016ecc9f3..6459b7c97 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -189,7 +189,7 @@ import { ClaudeLoginPromptButton, createClaudeLoginTerminalInWork } from "../wor import { CHAT_AUTH_RECOVERED_EVENT, CHAT_AUTH_RETRY_REJECTED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; import { rootAppStoreApi, selectActiveProjectRoot, useAppStore, useRootAppStore } from "../../state/appStore"; import { setLaneNaming } from "../../state/laneNamingStore"; -import { buildChatAppearanceRootStyle } from "./chatAppearance"; +import { buildChatAppearanceRootStyle, resolveChatContentWidthPx } from "./chatAppearance"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; import { buildLaneBindingIndex, @@ -926,9 +926,9 @@ function staleDraftLaunchJobMessage(job: DraftLaunchJob): string { const PANE_RESERVE_RIGHT_PX = 276; // 16.5rem pane + 12px gutter const PANE_RESERVE_LEFT_PX = 276; // 16.5rem pane + 12px gutter const CHAT_MIN_WIDTH_PX = 360; // recenter the chat as soon as a normal screen allows -// The centered chat column's default width (`--chat-column`, 52rem @ 16px root). -// Used to tell whether a floating pane already fits in the chat's side margin. -const CHAT_COLUMN_PX = 832; +// The centered chat column's width. NOT a constant any more: it is the JS half +// of the `--chat-content-width` token (chatAppearance.ts), so this maths and the +// CSS that lays the column out can never disagree. /** * Reserve gutter space for the floating panes — but ONLY when they'd otherwise * overlap the centered chat column. When the window is wide enough that a pane @@ -944,7 +944,7 @@ function computePaneReserve( ): { left: string; right: string } { if (width <= 0) return { left: "0px", right: "0px" }; // Free space on each side of the centered column at full width. - const naturalSideMargin = Math.max(0, (width - CHAT_COLUMN_PX) / 2); + const naturalSideMargin = Math.max(0, (width - resolveChatContentWidthPx(width)) / 2); let right = 0; if ( rightOpen @@ -12258,7 +12258,7 @@ export function AgentChatPane({ const SIDE_PANE_WIDTH_PX = 264; // 16.5rem const centeredPaneOffsetPx = Math.max( 12, - Math.round(((chatAreaWidth - CHAT_COLUMN_PX) / 2 - SIDE_PANE_WIDTH_PX) / 2), + Math.round(((chatAreaWidth - resolveChatContentWidthPx(chatAreaWidth)) / 2 - SIDE_PANE_WIDTH_PX) / 2), ); const rightPaneOffsetPx = paneReserve.right === "0px" ? centeredPaneOffsetPx : 12; const leftPaneOffsetPx = paneReserve.left === "0px" ? centeredPaneOffsetPx : 12; diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx index 882a58f7d..12147d187 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx @@ -31,6 +31,21 @@ function artifact(index: number, overrides: Partial = { }; } +function snapshotOf(artifacts: ComputerUseArtifactView[]): ComputerUseOwnerSnapshot { + return { + owner: { kind: "chat_session", id: "session-1" }, + backendStatus: { + backends: [], + localFallback: { available: true, detail: "Available", supportedKinds: ["screenshot"] }, + }, + summary: `${artifacts.length} proof item`, + activeBackend: null, + artifacts, + recentArtifacts: artifacts, + activity: [], + }; +} + beforeEach(() => { delete (window as unknown as { IntersectionObserver?: unknown }).IntersectionObserver; (window as unknown as { ade: unknown }).ade = { @@ -128,6 +143,52 @@ describe("proof rendering", () => { expect(onOpenDrawer).toHaveBeenCalledTimes(1); }); + it("deletes a drawer item and refreshes, instead of leaving the user no way to remove it", async () => { + const onRefresh = vi.fn().mockResolvedValue(undefined); + const deleteArtifacts = vi.fn().mockResolvedValue({ deleted: [], missing: [], failed: [], freedBytes: 0 }); + (window.ade as any).computerUse.deleteArtifacts = deleteArtifacts; + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Delete Proof 1" })); + + await waitFor(() => expect(deleteArtifacts).toHaveBeenCalledWith({ artifactId: "artifact-1" })); + await waitFor(() => expect(onRefresh).toHaveBeenCalled()); + }); + + it("explains why a never-imported capture is blank and offers to locate or remove it", async () => { + const readPreview = vi.mocked(window.ade.computerUse.readArtifactPreview); + const recoverArtifact = vi.fn().mockResolvedValue({}); + (window.ade as any).computerUse.recoverArtifact = recoverArtifact; + (window.ade as any).computerUse.deleteArtifacts = vi.fn().mockResolvedValue({}); + + const broken = artifact(4, { + title: "Lost proof", + availability: "unimported", + uri: "shots/proof.png", + metadata: { sourcePath: "shots/proof.png" }, + }); + render(); + + // Honest, specific copy — not "Preview unavailable from the connected runtime." + expect(await screen.findByText(/Never copied into ADE's storage/)).toBeTruthy(); + expect(screen.getByText(/1 item has no stored file/)).toBeTruthy(); + // No point asking the host for bytes it already told us do not exist. + expect(readPreview).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Locate Lost proof in its lane" })); + await waitFor(() => expect(recoverArtifact).toHaveBeenCalledWith({ artifactId: "artifact-4" })); + }); + + it("surfaces a failed delete instead of silently doing nothing", async () => { + (window.ade as any).computerUse.deleteArtifacts = vi.fn().mockRejectedValue(new Error("Artifact is locked")); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete Proof 1" })); + + expect(await screen.findByText("Artifact is locked")).toBeTruthy(); + }); + it("uses a remote-safe external action only for HTTP artifacts", async () => { const remote = artifact(3, { uri: "https://proof.example/capture.png", diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx index b7071e818..ffbff3f40 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx @@ -3,7 +3,9 @@ import { Cube, FileText, ImageSquare, + MagnifyingGlass, SpinnerGap, + Trash, VideoCamera, WarningCircle, X, @@ -48,6 +50,41 @@ function externalArtifactUrl(uri: string): string | null { return /^https?:\/\//i.test(uri) ? uri : null; } +/** + * Hosts that predate availability reporting omit the field. Treat that as + * "available" and let the preview read decide, rather than showing a broken + * state we have no evidence for. + */ +function artifactAvailability(artifact: ComputerUseArtifactView): "available" | "missing_file" | "unimported" { + return artifact.availability ?? "available"; +} + +function isBrokenArtifact(artifact: ComputerUseArtifactView): boolean { + return artifactAvailability(artifact) !== "available"; +} + +function shortSourcePath(artifact: ComputerUseArtifactView): string | null { + const source = artifact.metadata?.sourcePath ?? artifact.metadata?.absolutePath; + const value = typeof source === "string" ? source.trim() : ""; + if (!value) return null; + const segments = value.split(/[\\/]/).filter(Boolean); + return segments.length > 2 ? `…/${segments.slice(-2).join("/")}` : value; +} + +/** + * We know exactly why a tile is blank, so say it. "Preview unavailable" told + * the user nothing and offered nothing to do about it. + */ +function brokenArtifactExplanation(artifact: ComputerUseArtifactView): string { + const where = shortSourcePath(artifact); + if (artifactAvailability(artifact) === "unimported") { + return where + ? `Never copied into ADE's storage — it was left at ${where} in the lane it was captured in.` + : "Never copied into ADE's storage, so there are no bytes to show."; + } + return "The stored file has since been deleted."; +} + function localArtifactUrl(uri: string): string | null { return /^ade-artifact:\/\/project(?:\/|$)/i.test(uri) ? uri : null; } @@ -103,6 +140,9 @@ function useVisibleArtifactPreview( !visible || loaded || !artifact.uri + // The host already told us there are no bytes; asking for them anyway + // just burns an IPC round trip per tile to get null back. + || isBrokenArtifact(artifact) || (!isImageArtifact(artifact) && !isVideoArtifact(artifact)) ) return; const directPreview = allowLocalArtifactProtocol @@ -267,11 +307,15 @@ export function ChatProofArtifactCard({
) : mediaFailed || (loaded && !preview && (image || video)) ? ( -
- - - Preview unavailable{externalUrl ? " here. Open the source to view it." : " from the connected runtime."} - + // Broken proof shrinks. A full-height empty box wastes the most + // valuable space in a 322px rail to say nothing. +
+ +
+ {externalUrl + ? "This proof lives at its source. Open it to view." + : brokenArtifactExplanation(artifact)} +
) : preview && image ? ( + ) : preview && video ? ( +
+ +
+
+ {artifact.title} +
+
+ {relativeTime(artifact.createdAt)} +
+ {broken ? ( +
+ {externalUrl ? "Stored at its source." : brokenArtifactExplanation(artifact)} +
+ ) : null} +
+ + {lightboxOpen && preview && image ? ( + setLightboxOpen(false)} + /> + ) : null} +
+ ); +} + export function ChatComputerUsePanel({ snapshot, onRefresh, @@ -391,7 +581,60 @@ export function ChatComputerUsePanel({ onRefresh: () => void | Promise; allowLocalArtifactProtocol?: boolean; }) { - const artifacts = snapshot?.artifacts ?? []; + // Stable identity: `?? []` would hand every memo below a fresh array each + // render and defeat them. + const artifacts = useMemo(() => snapshot?.artifacts ?? [], [snapshot]); + const [busyIds, setBusyIds] = useState>(() => new Set()); + const [error, setError] = useState(null); + + const brokenCount = useMemo( + () => artifacts.filter((artifact) => isBrokenArtifact(artifact)).length, + [artifacts], + ); + + const withBusy = useCallback( + async (ids: string[], run: () => Promise) => { + setError(null); + setBusyIds((current) => new Set([...current, ...ids])); + try { + await run(); + await onRefresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusyIds((current) => { + const next = new Set(current); + for (const id of ids) next.delete(id); + return next; + }); + } + }, + [onRefresh], + ); + + const handleDelete = useCallback( + (artifact: ComputerUseArtifactView) => { + void withBusy([artifact.id], () => + window.ade.computerUse.deleteArtifacts({ artifactId: artifact.id }), + ); + }, + [withBusy], + ); + + const handleRecover = useCallback( + (artifact: ComputerUseArtifactView) => { + void withBusy([artifact.id], () => + window.ade.computerUse.recoverArtifact({ artifactId: artifact.id }), + ); + }, + [withBusy], + ); + + const handlePruneBroken = useCallback(() => { + const ids = artifacts.filter((artifact) => isBrokenArtifact(artifact)).map((artifact) => artifact.id); + if (!ids.length) return; + void withBusy(ids, () => window.ade.computerUse.deleteArtifacts({ artifactIds: ids })); + }, [artifacts, withBusy]); if (!snapshot || artifacts.length === 0) { return ( @@ -408,10 +651,10 @@ export function ChatComputerUsePanel({ } return ( -
-
+
+
- {artifacts.length} item{artifacts.length === 1 ? "" : "s"} collected + {artifacts.length} item{artifacts.length === 1 ? "" : "s"}
-
+ + {brokenCount > 0 ? ( +
+ + {brokenCount} item{brokenCount === 1 ? " has" : "s have"} no stored file. + + +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
{artifacts.map((artifact) => ( - ))}
diff --git a/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx index a88effa69..1ce7ea057 100644 --- a/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react"; import { CaretDown, + CaretRight, FileCode, FilePlus, FileX, @@ -15,6 +16,12 @@ import type { import { AdeDiffViewer } from "../shared/AdeDiffViewer"; import { cn } from "../ui/cn"; import { BottomDrawerSection } from "./BottomDrawerSection"; +import { + CHAT_CARD_WIDTH_CLASS, + ChatCardDiffStat, + ChatCardRow, + ChatCardTitle, +} from "./chatCardPrimitives"; /* ── Helpers ── */ @@ -348,6 +355,14 @@ function NestedFileChangesSection({ ); } +/** + * Turn-level "files changed" — ONE line by default. + * + * `7 files changed` with the diff stat in the meta column and a `diff ›` + * affordance; the browser only unfolds when asked. It uses the shared + * `[glyph | content | meta]` grid so its title and numbers sit in the same + * columns as the CI, PR and agent cards above and below it. + */ export const ChatTurnFileChangesPanel = React.memo(function ChatTurnFileChangesPanel({ turnSummary, threadSummaries, @@ -361,15 +376,27 @@ export const ChatTurnFileChangesPanel = React.memo(function ChatTurnFileChangesP const turnFiles = useMemo(() => aggregateFiles([turnSummary]), [turnSummary]); if (!turnFiles.length) return null; + const additions = turnFiles.reduce((sum, file) => sum + file.additions, 0); + const deletions = turnFiles.reduce((sum, file) => sum + file.deletions, 0); + return ( -
- - - Files changed - - - View diffs - +
+ + } + action={( + + diff + + + )} + > + + {turnFiles.length} file{turnFiles.length === 1 ? "" : "s"} changed + +
diff --git a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx index acc32c138..103ce72c7 100644 --- a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx @@ -656,7 +656,24 @@ export function ChatToolActivityDetails({ sessionId?: string | null; }) { const activityEntries = useMemo(() => dedupeChatToolActivityEntries(entries), [entries]); + // Keep the LAST call — the one that says what the agent is doing now — plus + // anything that FAILED, and fold the quiet successes behind a count. A 50-row + // scroll box of finished tool calls is noise; a rejected hook or a failed + // command is not, and must never be what the fold hides. + const [showHistory, setShowHistory] = useState(false); + const visibleEntries = useMemo(() => { + if (showHistory || activityEntries.length <= 1) return activityEntries; + const lastIndex = activityEntries.length - 1; + return activityEntries.filter((entry, index) => ( + index === lastIndex + || entry.tone === "error" + || entry.status === "failed" + || entry.status === "interrupted" + || entry.status === "running" + )); + }, [activityEntries, showHistory]); if (activityEntries.length === 0) return null; + const hiddenCount = activityEntries.length - visibleEntries.length; return (
+ {hiddenCount > 0 ? ( + + ) : null}
- {activityEntries.map((entry) => ( + {visibleEntries.map((entry) => ( ))}
diff --git a/apps/desktop/src/renderer/components/chat/ContextCompactDivider.tsx b/apps/desktop/src/renderer/components/chat/ContextCompactDivider.tsx index 6857f4810..ca752fd78 100644 --- a/apps/desktop/src/renderer/components/chat/ContextCompactDivider.tsx +++ b/apps/desktop/src/renderer/components/chat/ContextCompactDivider.tsx @@ -42,7 +42,8 @@ export function ContextCompactDivider({ event }: ContextCompactDividerProps) { ) : null} - {event.agentType?.trim() && event.agentType.trim() !== "background" ? ( - - {event.agentType.trim()} + {/* Role, not id. Codex hands us its internal agent path + (`/ROOT/SHIP_POLL_927`); `uppercase` on top of that made it shout a + file path at the reader. `humanizeAgentIdentity` turns the last + segment into a role and lifts a trailing issue/PR number into its + own chip; runtimes that never set an agent type (OpenCode, Droid) + get null and render no chip at all. The raw value stays as the + tooltip so nothing is lost. */} + {agentIdentity ? ( + + {agentIdentity.label} + + ) : null} + {agentIdentity?.ref ? ( + + {agentIdentity.ref} ) : null} {event.background ? ( @@ -207,11 +234,18 @@ export function SubagentSpawnCard({ } /** - * Result card at the chronological position where the agent ended. Status + - * duration, a ~2-line preview of the final report, "View transcript", and a - * "↑ jump to start" affordance. Warm terminal states: stopped → amber tone, - * failed → reason chip + a `Details` disclosure with the error text (never a red - * error block). + * Result card at the chronological position where the agent ended — the "role + + * result" shape: what it was, what it found, how long it took. + * + * The head line is the agent's TASK, not the word "Finished": a transcript full + * of `Finished / Finished / Finished` says nothing, and the status is already + * carried by the glyph and the tone. The body is the real report preview; + * runtime filler (`"Agent completed"`) is filtered out by + * {@link firstMeaningfulSummary} and falls back to the status word rather than + * printing placeholder text where a result belongs. + * + * Warm terminal states throughout: stopped → amber tone, failed → an `error` + * chip plus a `Details` disclosure. Never a red error block. */ export function SubagentResultCard({ event, @@ -227,96 +261,90 @@ export function SubagentResultCard({ const isStopped = event.status === "stopped"; const isFailed = event.status === "failed"; const duration = formatSubagentDurationMs(event.durationMs); + const statusWord = isSuccess ? "Finished" : isStopped ? "Stopped — interrupted" : "Failed"; + const title = event.description?.trim() || statusWord; + const summary = firstMeaningfulSummary(event.summaryPreview); - const toneCard = isSuccess - ? "border-[color:color-mix(in_srgb,var(--chat-accent)_14%,transparent)] bg-[color:color-mix(in_srgb,var(--chat-accent)_5%,transparent)]" - : isStopped - ? "border-amber-300/14 bg-amber-300/[0.045]" - : "border-amber-400/16 bg-amber-400/[0.05]"; - const statusLabel = isSuccess ? "Finished" : isStopped ? "stopped — interrupted" : "Failed"; - const statusColor = isSuccess ? "text-fg/70" : "text-amber-100/85"; + const counters = [ + typeof event.toolUseCount === "number" && event.toolUseCount > 0 + ? `${event.toolUseCount} tool${event.toolUseCount === 1 ? "" : "s"}` + : null, + typeof event.totalTokens === "number" && event.totalTokens > 0 + ? `${formatContextTokens(event.totalTokens)} tokens` + : null, + ].filter((part): part is string => Boolean(part)); return ( -
-
- - {isSuccess ? ( - - ) : isStopped ? ( - - ) : ( - - )} - -
-
- - {statusLabel} - - {duration ? ( - {duration} - ) : null} - {typeof event.toolUseCount === "number" && event.toolUseCount > 0 ? ( - - · {event.toolUseCount} tool{event.toolUseCount === 1 ? "" : "s"} - - ) : null} - {typeof event.totalTokens === "number" && event.totalTokens > 0 ? ( - - · {formatContextTokens(event.totalTokens)} tokens - - ) : null} - {isFailed && event.error?.trim() ? ( - - error - - ) : null} - {event.worktreeBranch?.trim() ? ( + + + {onViewTranscript ? ( ) : null} - {event.parentLabel ? ( - spawned by {event.parentLabel} + {onJumpToStart ? ( + ) : null} -
- {event.summaryPreview?.trim() ? ( -
- {event.summaryPreview.trim()} -
+ + ) : null} + > +
+ {title} + {!isSuccess && event.description?.trim() ? ( + + {statusWord.toLowerCase()} + ) : null} -
-
- {onViewTranscript ? ( - + {isFailed && event.error?.trim() ? ( + + error + ) : null} - {onJumpToStart ? ( + {event.worktreeBranch?.trim() ? ( ) : null} + {event.parentLabel ? ( + spawned by {event.parentLabel} + ) : null}
-
+ {summary ? ( +
+ {summary} +
+ ) : null} + {counters.length ? ( +
+ {counters.join(" · ")} +
+ ) : null} + {isFailed && event.error?.trim() ? ( -
+
) : null} -
+ ); } @@ -354,7 +382,7 @@ export function BackgroundFinishChip({ event }: { event: BackgroundFinishChipRen return (
void; }) { - const [expanded, setExpanded] = useState(false); const count = event.count; + const [expanded, setExpanded] = useState(count <= 6); const headline = `${count} ${count === 1 ? "agent" : "agents"} stopped when you interrupted`; return ( -
- + + {expanded ? ( -
    + {event.items.map((item) => ( -
  • - - {item.title} - - {onJumpToStart ? ( - - ) : null} -
  • + onJumpToStart(item.jumpToStartRowKey) : undefined} + /> ))} -
+ ) : null} -
+ ); } diff --git a/apps/desktop/src/renderer/components/chat/chatAppearance.ts b/apps/desktop/src/renderer/components/chat/chatAppearance.ts index b3e3987da..3b18e5a61 100644 --- a/apps/desktop/src/renderer/components/chat/chatAppearance.ts +++ b/apps/desktop/src/renderer/components/chat/chatAppearance.ts @@ -50,6 +50,48 @@ export function transcriptBubblePaddingPx(density: ChatTranscriptDensity): { } } +/** + * The transcript's ONE content width. + * + * Every row — prose, cards, plan, files-changed, activity bundles, pills — + * clamps to `--chat-content-width` and nothing else. Before this token there + * were seven disagreeing clamps (832 / 804 / 736 / 704 / 672 / 616 / 544 px), + * and the card clamp — 70 characters, which resolved against the browser's 16px + * default because no card sets a font-size — stopped ~26% short of the prose it + * sat under. + * + * It scales with the viewport on purpose: a fixed 832px wastes most of a 27" + * display. `min(100%, clamp(min, vw, max))` keeps the reading measure sane on a + * laptop, grows on a large screen, and never exceeds its container — so an open + * side pane (which shrinks the container, not the viewport) narrows the column + * rather than clipping it. + */ +export const CHAT_CONTENT_WIDTH_MIN_PX = 720; +export const CHAT_CONTENT_WIDTH_MAX_PX = 1180; +export const CHAT_CONTENT_WIDTH_VIEWPORT_RATIO = 0.62; + +/** CSS half of the token. Must stay in lockstep with {@link resolveChatContentWidthPx}. */ +export const CHAT_CONTENT_WIDTH_CSS = + `min(100%, clamp(${CHAT_CONTENT_WIDTH_MIN_PX}px, ${CHAT_CONTENT_WIDTH_VIEWPORT_RATIO * 100}vw, ${CHAT_CONTENT_WIDTH_MAX_PX}px))`; + +/** + * JS half of the token, for layout maths that cannot read a CSS `clamp()` + * (the chat pane's floating-pane reserve). Same inputs, same answer. + */ +export function resolveChatContentWidthPx( + availableWidthPx: number, + viewportWidthPx?: number, +): number { + const viewport = Number.isFinite(viewportWidthPx) && (viewportWidthPx ?? 0) > 0 + ? (viewportWidthPx as number) + : (typeof window !== "undefined" && window.innerWidth > 0 ? window.innerWidth : availableWidthPx); + const target = Math.min( + CHAT_CONTENT_WIDTH_MAX_PX, + Math.max(CHAT_CONTENT_WIDTH_MIN_PX, viewport * CHAT_CONTENT_WIDTH_VIEWPORT_RATIO), + ); + return availableWidthPx > 0 ? Math.min(availableWidthPx, target) : target; +} + /** CSS vars for transcript + composer (scoped under `[data-chat-appearance-root]`). */ export function buildChatAppearanceRootStyle(params: { chatFontSizePx: number; @@ -64,6 +106,11 @@ export function buildChatAppearanceRootStyle(params: { return { ["--chat-font-size" as string]: `${params.chatFontSizePx}px`, + ["--chat-content-width" as string]: CHAT_CONTENT_WIDTH_CSS, + // `--chat-column` predates the token and is read by the composer and the + // pane's draft-launch rows. Aliasing it here keeps composer and transcript + // on ONE width instead of two independent definitions. + ["--chat-column" as string]: "var(--chat-content-width)", ["--chat-row-gap" as string]: `${gap}px`, ["--chat-timeline-pad-x" as string]: `${padX}px`, ["--chat-timeline-pad-top" as string]: `${padTop}px`, diff --git a/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts new file mode 100644 index 000000000..cf033700b --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + firstMeaningfulSummary, + formatScheduledRunAt, + humanizeAgentIdentity, + isPlaceholderSummary, +} from "./chatCardPrimitives"; + +describe("humanizeAgentIdentity", () => { + // `/ROOT/SHIP_POLL_927` is Codex's internal agent path. Rendered raw (and + // CSS-uppercased on top) it shouted a file path where a role belonged. + it("turns an internal agent path into a role plus an issue reference", () => { + expect(humanizeAgentIdentity("/ROOT/SHIP_POLL_927")).toEqual({ + label: "Ship poll", + ref: "#927", + raw: "/ROOT/SHIP_POLL_927", + }); + }); + + it("keeps the raw value for the tooltip so nothing is lost", () => { + expect(humanizeAgentIdentity("/root/review_fixer")?.raw).toBe("/root/review_fixer"); + expect(humanizeAgentIdentity("/root/review_fixer")?.label).toBe("Review fixer"); + expect(humanizeAgentIdentity("/root/review_fixer")?.ref).toBeNull(); + }); + + it("renders no chip for the runtimes that never set an agent type", () => { + // OpenCode / Droid send nothing at all. + expect(humanizeAgentIdentity(null)).toBeNull(); + expect(humanizeAgentIdentity(undefined)).toBeNull(); + expect(humanizeAgentIdentity(" ")).toBeNull(); + // `background` is already its own chip on the spawn card. + expect(humanizeAgentIdentity("background")).toBeNull(); + // A bare path root carries no role. + expect(humanizeAgentIdentity("/root")).toBeNull(); + }); + + it("passes an already-human agent type through as sentence case", () => { + expect(humanizeAgentIdentity("Explore")?.label).toBe("Explore"); + expect(humanizeAgentIdentity("claude")?.label).toBe("Claude"); + }); + + it("does not mistake a version-like tail for an issue number", () => { + expect(humanizeAgentIdentity("worker_5")?.label).toBe("Worker 5"); + expect(humanizeAgentIdentity("worker_5")?.ref).toBeNull(); + }); +}); + +describe("firstMeaningfulSummary", () => { + it("rejects the runtime filler that used to be printed as a result", () => { + expect(isPlaceholderSummary("Agent completed")).toBe(true); + expect(isPlaceholderSummary("Agent received input")).toBe(true); + expect(isPlaceholderSummary("Agent active.")).toBe(true); + expect(isPlaceholderSummary("")).toBe(true); + }); + + it("returns the first candidate that actually says something", () => { + expect(firstMeaningfulSummary("Agent completed", "Head ccce46c4b is stable.")) + .toBe("Head ccce46c4b is stable."); + expect(firstMeaningfulSummary("Agent completed", null)).toBeNull(); + }); +}); + +describe("formatScheduledRunAt", () => { + const now = Date.parse("2026-07-28T12:13:18.016Z"); + + it("formats a schedule as a relative distance plus a local clock", () => { + const formatted = formatScheduledRunAt("2026-07-28T12:17:18.016Z", now); + expect(formatted).toContain("runs in 4m"); + // Never the raw ISO string. + expect(formatted).not.toContain("2026-07-28T"); + }); + + it("handles hours, days and the past", () => { + expect(formatScheduledRunAt("2026-07-28T14:43:18.016Z", now)).toContain("runs in 2h 30m"); + expect(formatScheduledRunAt("2026-07-30T12:13:18.016Z", now)).toContain("runs in 2d"); + expect(formatScheduledRunAt("2026-07-28T12:08:18.016Z", now)).toContain("ran 5m ago"); + }); + + it("returns null rather than echoing an unparseable value", () => { + expect(formatScheduledRunAt(null)).toBeNull(); + expect(formatScheduledRunAt("")).toBeNull(); + expect(formatScheduledRunAt("not-a-date")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx new file mode 100644 index 000000000..041e65cd7 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx @@ -0,0 +1,551 @@ +import React from "react"; +import { + CaretRight, + CheckCircle, + Circle, + Cube, + Prohibit, + SpinnerGap, + XCircle, + type Icon as PhosphorIcon, +} from "@phosphor-icons/react"; +import { cn } from "../ui/cn"; +import type { ComputerUseArtifactView } from "../../../shared/types"; + +/** + * The chat transcript's card primitives. + * + * One layout idea holds the whole transcript together: every card row is a + * `[16px glyph] [flexible content] [auto meta]` grid, so titles, durations and + * counts line up in columns down the entire thread instead of each card + * inventing its own indent. Cards compose `CardRow` + one of the `CARD_SKIN` + * shells; nothing here reaches for a bespoke flex arrangement. + * + * Two house rules are enforced here rather than restated per card: + * + * 1. **Width.** `CHAT_CARD_WIDTH_CLASS` is the ONLY max-width a transcript row + * may use. It resolves `--chat-content-width` (see `chatAppearance.ts`), the + * single viewport-scaling token every row — prose, cards, pills, plan, + * files-changed — shares. Card-local clamps are what made cards stop ~26% + * short of the prose they sat under. + * 2. **No red.** A failure is amber (`warn`), never a red error block — the same + * policy stated at the top of `../../../shared/adeCard.ts` and + * `./SubagentActivityCards.tsx`. + */ + +/** The single transcript content width. Never re-clamp a row on top of this. */ +export const CHAT_CARD_WIDTH_CLASS = "w-full max-w-[var(--chat-content-width,52rem)]"; + +/** Type scale, `--chat-font-size`-relative so the appearance setting still drives it. */ +export const CHAT_CARD_TITLE_TEXT = "text-[length:calc(var(--chat-font-size)*12/14)]"; +export const CHAT_CARD_BODY_TEXT = "text-[length:calc(var(--chat-font-size)*10.5/14)]"; +export const CHAT_CARD_META_TEXT = "text-[length:calc(var(--chat-font-size)*10/14)]"; +export const CHAT_CARD_MICRO_TEXT = "text-[length:calc(var(--chat-font-size)*9.5/14)]"; + +/** Radius 10px at the default 16px card radius — the house `-6px` idiom. */ +const CARD_RADIUS = "rounded-[calc(var(--chat-radius-card)-6px)]"; + +export type ChatCardTone = "ok" | "warn" | "running" | "idle" | "neutral"; + +/** Status tone → glyph + colour. One vocabulary for every card. */ +export const CHAT_CARD_TONE: Record = { + ok: { cls: "text-emerald-300/85", Icon: CheckCircle }, + warn: { cls: "text-amber-300/85", Icon: XCircle }, + running: { cls: "text-[color:var(--chat-accent)]", Icon: SpinnerGap }, + idle: { cls: "text-fg/32", Icon: Prohibit }, + neutral: { cls: "text-fg/45", Icon: Circle }, +}; + +export type ChatCardSkin = "line" | "inset" | "bordered" | "rail" | "plain"; + +/** Card shells. `line` is the default — no box, just a hairline rule. */ +export const CHAT_CARD_SKIN: Record = { + line: "px-0.5 py-2 border-b border-white/[0.06] last:border-b-0", + inset: `px-3 py-2.5 bg-white/[0.03] ${CARD_RADIUS}`, + bordered: `px-3 py-2.5 bg-white/[0.025] border border-white/[0.07] ${CARD_RADIUS}`, + rail: `px-3 py-2.5 bg-white/[0.03] border-l-2 rounded-r-[calc(var(--chat-radius-card)-6px)]`, + plain: "py-1", +}; + +const RAIL_BORDER_COLOR: Record = { + ok: "rgb(110 231 183 / 0.5)", + warn: "rgb(252 211 77 / 0.5)", + running: "var(--chat-accent)", + idle: "rgb(255 255 255 / 0.12)", + neutral: "rgb(255 255 255 / 0.12)", +}; + +export function ChatCard({ + skin = "line", + tone = "neutral", + className, + children, + ...rest +}: { + skin?: ChatCardSkin; + tone?: ChatCardTone; + className?: string; + children: React.ReactNode; +} & Omit, "children" | "className">) { + return ( +
+ {children} +
+ ); +} + +/** + * The single layout primitive every card uses: + * `[16px glyph] [flexible content] [auto meta]`. Keeping this grid identical + * everywhere is what makes a scrolled transcript read as columns instead of a + * ragged stack. + */ +export function ChatCardRow({ + tone = "neutral", + icon, + children, + meta, + action, + align = "center", + className, +}: { + tone?: ChatCardTone; + /** Overrides the tone's default glyph (e.g. a merge or clock icon). */ + icon?: PhosphorIcon; + children?: React.ReactNode; + meta?: React.ReactNode; + action?: React.ReactNode; + align?: "center" | "top"; + className?: string; +}) { + const toneSpec = CHAT_CARD_TONE[tone] ?? CHAT_CARD_TONE.neutral; + const Icon = icon ?? toneSpec.Icon; + return ( +
+ + + +
{children}
+
+ {meta != null && meta !== "" ? ( + {meta} + ) : null} + {action} +
+
+ ); +} + +export function ChatCardTitle({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +export function ChatCardSub({ children, className }: { children: React.ReactNode; className?: string }) { + return
{children}
; +} + +/** Secondary half of a title — "Merged #927 → main", where the tail is faint. */ +export function ChatCardFaint({ children }: { children: React.ReactNode }) { + return {children}; +} + +export function ChatCardChip({ + tone = "neutral", + title, + children, +}: { + tone?: ChatCardTone; + title?: string; + children: React.ReactNode; +}) { + const cls = { + ok: "text-emerald-300/85 border-emerald-300/25", + warn: "text-amber-300/85 border-amber-300/25", + running: "text-[color:var(--chat-accent)] border-[color:color-mix(in_srgb,var(--chat-accent)_30%,transparent)]", + idle: "text-fg/40 border-white/[0.07]", + neutral: "text-fg/48 border-white/[0.07]", + }[tone]; + return ( + + {children} + + ); +} + +/** Text-weight affordance ("diff ›", "open all ›") that sits in a row's meta column. */ +export function ChatCardAction({ + children, + onClick, + title, +}: { + children: React.ReactNode; + onClick?: () => void; + title?: string; +}) { + return ( + + ); +} + +export function ChatCardButton({ + primary, + children, + onClick, + title, +}: { + primary?: boolean; + children: React.ReactNode; + onClick?: () => void; + title?: string; +}) { + return ( + + ); +} + +export type ChatCardProgress = { passed: number; failed: number; running: number; queued: number }; + +/** Segmented progress hairline — passed / failed / running / queued. */ +export function ChatCardMeter({ progress, className }: { progress: ChatCardProgress; className?: string }) { + const segments: Array<[keyof ChatCardProgress, string]> = [ + ["passed", "bg-emerald-400/70"], + ["failed", "bg-amber-400/80"], + ["running", "bg-[color:var(--chat-accent)]"], + ["queued", "bg-white/12"], + ]; + const total = segments.reduce((sum, [bucket]) => sum + Math.max(0, progress[bucket]), 0); + if (total <= 0) return null; + return ( +
+ {segments.map(([bucket, fill]) => { + const value = Math.max(0, progress[bucket]); + if (value <= 0) return null; + return ; + })} +
+ ); +} + +/** + * Detail rows hang under a card head, separated by a hairline. `path` flips the + * text direction so a truncated file path keeps its filename visible. + */ +export function ChatCardDetail({ children, className }: { children: React.ReactNode; className?: string }) { + return
{children}
; +} + +export function ChatCardDetailRow({ + tone = "neutral", + label, + value, + strike, + path, + title, + onClick, +}: { + tone?: ChatCardTone; + label: React.ReactNode; + value?: React.ReactNode; + strike?: boolean; + /** RTL truncation — keeps the tail (the filename) of a long path readable. */ + path?: boolean; + title?: string; + onClick?: () => void; +}) { + const toneSpec = CHAT_CARD_TONE[tone] ?? CHAT_CARD_TONE.neutral; + const Icon = toneSpec.Icon; + const body = ( + <> + + + + + {label} + + {value} + + ); + const className = cn( + "grid grid-cols-[12px_minmax(0,1fr)_auto] items-center gap-2 py-[3px]", + CHAT_CARD_BODY_TEXT, + ); + if (!onClick) { + return ( +
+ {body} +
+ ); + } + return ( + + ); +} + +export function ChatCardDiffStat({ additions, deletions }: { additions: number; deletions: number }) { + return ( + + +{additions.toLocaleString()} + −{deletions.toLocaleString()} + + ); +} + +/** + * A centred hairline rule with a mono cutout — the transcript's turn separator + * (`10:04 · ran 3m 32s`). + */ +export function ChatTurnRule({ label, children }: { label?: React.ReactNode; children?: React.ReactNode }) { + return ( +
+ + {label} + {children} + +
+ ); +} + +/* ── Proof ──────────────────────────────────────────────────────────────── */ + +/** + * Collapsible proof filmstrip: a `▣ Proof · N` head plus a horizontally + * scrollable thumbnail strip. Rendered inline at the point of capture — proof + * is a chronological transcript row, not a thread footer. + * + * Exported for the proof drawer to reuse; it owns no artifact loading of its + * own beyond the image `src` each caller supplies. + */ +export function ChatProofFilmstrip({ + artifacts, + title = "Proof", + defaultOpen = true, + onOpenAll, + onOpenArtifact, + resolveThumbnailSrc, +}: { + artifacts: ComputerUseArtifactView[]; + title?: string; + defaultOpen?: boolean; + onOpenAll?: () => void; + onOpenArtifact?: (artifact: ComputerUseArtifactView) => void; + /** Returns a renderable image URL, or null when the artifact has no preview. */ + resolveThumbnailSrc?: (artifact: ComputerUseArtifactView) => string | null; +}) { + const [open, setOpen] = React.useState(defaultOpen); + if (!artifacts.length) return null; + + return ( + + open all : null} + > + + + {open ? ( +
+ {artifacts.map((artifact) => { + const src = resolveThumbnailSrc?.(artifact) ?? null; + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +/* ── Text helpers ───────────────────────────────────────────────────────── */ + +/** Codex filler that must never be printed where a real result belongs. */ +const PLACEHOLDER_SUMMARIES = new Set([ + "agent completed", + "agent received input", + "agent active", + "agent started", + "agent running", + "agent finished", + "agent stopped", +]); + +export function isPlaceholderSummary(value: string | null | undefined): boolean { + const text = (value ?? "").trim().toLowerCase().replace(/[.!]+$/, ""); + if (!text) return true; + return PLACEHOLDER_SUMMARIES.has(text); +} + +/** + * First non-placeholder candidate, or null. Callers fall back to the status + * word rather than printing "Agent completed" where a result should be. + */ +export function firstMeaningfulSummary(...candidates: Array): string | null { + for (const candidate of candidates) { + const text = candidate?.trim(); + if (text && !isPlaceholderSummary(text)) return text; + } + return null; +} + +export type AgentIdentityLabel = { + /** Human role, sentence case — "Ship poller". */ + label: string; + /** Trailing issue/PR number lifted out of the path, e.g. "#927". */ + ref: string | null; + /** The raw value, kept for the `title` tooltip. */ + raw: string; +}; + +/** + * Turn a runtime's internal agent path into a role. + * + * Codex hands us `/ROOT/SHIP_POLL_927` — an id, not an identity. Identity + * should read as role + intent, so the last segment becomes sentence case + * ("Ship poll") and a trailing issue/PR number is lifted into its own chip. + * Runtimes that never set an agent type (OpenCode, Droid) get `null` and + * render no chip at all. + */ +export function humanizeAgentIdentity(value: string | null | undefined): AgentIdentityLabel | null { + const raw = (value ?? "").trim(); + if (!raw) return null; + // `background` is a flag the spawn card already renders as its own chip. + if (raw.toLowerCase() === "background") return null; + const segments = raw.split(/[/\\]+/).filter((segment) => segment.length > 0); + let tail = segments.length ? segments[segments.length - 1]! : raw; + // A bare "/root" (or an all-separator value) carries no role at all. + if (segments.length === 1 && /^root$/i.test(tail)) return null; + let ref: string | null = null; + const numberMatch = tail.match(/[_\-\s](\d{2,})$/); + if (numberMatch) { + ref = `#${numberMatch[1]}`; + tail = tail.slice(0, numberMatch.index); + } + const words = tail.split(/[_\-\s]+/).filter((word) => word.length > 0); + if (!words.length) return ref ? { label: raw, ref, raw } : null; + const joined = words.join(" ").toLowerCase(); + const label = joined.charAt(0).toUpperCase() + joined.slice(1); + return { label, ref, raw }; +} + +/** + * `runs in 4m · 12:17` — a schedule is only actionable as a relative distance + * plus a local wall clock; a raw ISO string is neither. + */ +export function formatScheduledRunAt(value: string | null | undefined, nowMs = Date.now()): string | null { + const raw = (value ?? "").trim(); + if (!raw) return null; + const at = Date.parse(raw); + if (!Number.isFinite(at)) return null; + const clock = new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + const deltaMs = at - nowMs; + const absMinutes = Math.round(Math.abs(deltaMs) / 60_000); + const relative = absMinutes < 1 + ? (deltaMs >= 0 ? "runs now" : "ran just now") + : absMinutes < 60 + ? (deltaMs >= 0 ? `runs in ${absMinutes}m` : `ran ${absMinutes}m ago`) + : absMinutes < 60 * 24 + ? (() => { + const hours = Math.floor(absMinutes / 60); + const minutes = absMinutes % 60; + const span = minutes ? `${hours}h ${minutes}m` : `${hours}h`; + return deltaMs >= 0 ? `runs in ${span}` : `ran ${span} ago`; + })() + : (() => { + const days = Math.round(absMinutes / (60 * 24)); + return deltaMs >= 0 ? `runs in ${days}d` : `ran ${days}d ago`; + })(); + return `${relative} · ${clock}`; +} diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 21c0ded33..5d8abefc9 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -2958,6 +2958,50 @@ describe("ade_card transcript rows", () => { expect(merged.metrics).toEqual([{ label: "files", value: "1" }]); }); + // `buildPrCiCard` ALWAYS writes `rows` and `progress`, so the "omitted means + // partial patch" comment was not enough on its own: a poll that came back + // from a rate-limited GitHub used to overwrite a good card with `rows: []` + // and an all-zero progress bar. + it("preserves prior detail — and marks it stale — when a degraded re-emit lands", () => { + const rows = collapseChatTranscriptEvents([ + env("2026-07-27T10:00:00.000Z", card({ + variant: "pr_ci", + rows: [{ icon: "fail", text: "test-desktop" }], + progress: { passed: 28, failed: 2, running: 0, queued: 0 }, + metrics: [{ label: "failed", value: "2" }], + })), + env("2026-07-27T10:00:01.000Z", card({ + variant: "pr_ci", + rows: [], + progress: { passed: 0, failed: 0, running: 0, queued: 0 }, + metrics: [], + degradedReason: "HTTP 403: rate limited", + })), + ]); + + expect(rows).toHaveLength(1); + const merged = rows[0]!.event; + if (merged.type !== "ade_card") throw new Error("Expected ade_card"); + expect(merged.rows).toEqual([{ icon: "fail", text: "test-desktop" }]); + expect(merged.progress).toEqual({ passed: 28, failed: 2, running: 0, queued: 0 }); + expect(merged.metrics).toEqual([{ label: "failed", value: "2" }]); + expect(merged.stale).toBe(true); + expect(merged.degradedReason).toBe("HTTP 403: rate limited"); + }); + + it("clears the stale marker as soon as a healthy emit brings detail back", () => { + const rows = collapseChatTranscriptEvents([ + env("2026-07-27T10:00:00.000Z", card({ rows: [{ icon: "fail", text: "lint" }] })), + env("2026-07-27T10:00:01.000Z", card({ rows: [], degradedReason: "HTTP 403" })), + env("2026-07-27T10:00:02.000Z", card({ rows: [{ icon: "pass", text: "lint" }], degradedReason: null })), + ]); + + const merged = rows[0]!.event; + if (merged.type !== "ade_card") throw new Error("Expected ade_card"); + expect(merged.stale).toBe(false); + expect(merged.rows).toEqual([{ icon: "pass", text: "lint" }]); + }); + it("keeps distinct cardIds as distinct rows", () => { const rows = collapseChatTranscriptEvents([ env("2026-07-27T10:00:00.000Z", card({ cardId: "run-1" })), diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index e5cd67cd1..9093a15ff 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -15,7 +15,7 @@ import { type NormalizedSubagentLifecycleEvent, } from "../../../shared/chatSubagents"; import { backgroundCommandLabel } from "../../../shared/chatScheduledWork"; -import { adeCardRowKey } from "../../../shared/adeCard"; +import { adeCardProgressTotal, adeCardRowKey } from "../../../shared/adeCard"; import { contextCompactMergeKey, isContextCompactionChatEvent, @@ -1040,6 +1040,54 @@ function resolveAdeCardRowIndex( return null; } +type AdeCardEvent = Extract; + +/** + * Merge a repeat `ade_card` emit into the row that is already in the transcript. + * + * A re-emit is a PATCH, not a replacement — but the emitters do not honour that + * on their own: `buildPrCiCard` always writes `rows` and `progress`, so a poll + * that came back from a rate-limited GitHub used to overwrite a good card with + * `rows: []` and an all-zero progress bar. A card that has already shown real + * detail therefore keeps it whenever the incoming payload has none, and is + * flagged `stale` so the surface can say "this is the last thing we knew" + * rather than silently showing old numbers as current. + * + * The flag clears itself the moment a healthy emit brings detail back. + */ +function mergeAdeCardEvent( + existing: AdeCardEvent, + incoming: AdeCardEvent, + cardId: string, +): AdeCardEvent { + const merged: AdeCardEvent = { ...existing, ...incoming, cardId }; + + const incomingRows = incoming.rows ?? []; + const incomingMetrics = incoming.metrics ?? []; + const incomingProgressTotal = adeCardProgressTotal(incoming.progress); + const existingHadDetail = (existing.rows?.length ?? 0) > 0 + || (existing.metrics?.length ?? 0) > 0 + || adeCardProgressTotal(existing.progress) > 0; + const incomingHasDetail = incomingRows.length > 0 + || incomingMetrics.length > 0 + || incomingProgressTotal > 0; + + if (existingHadDetail && (!incomingHasDetail || incoming.degradedReason)) { + if (!incomingRows.length && existing.rows?.length) merged.rows = existing.rows; + if (!incomingMetrics.length && existing.metrics?.length) merged.metrics = existing.metrics; + if (incomingProgressTotal === 0 && existing.progress) merged.progress = existing.progress; + if (existing.rowsTruncated != null && incoming.rowsTruncated == null) { + merged.rowsTruncated = existing.rowsTruncated; + } + merged.stale = true; + return merged; + } + + // Healthy emit: drop the stale marker the previous degraded emit left behind. + if (incomingHasDetail) merged.stale = incoming.stale ?? false; + return merged; +} + function removeCollapsedTranscriptRow( rows: ChatTranscriptRenderEnvelope[], context: CollapseTranscriptContext, @@ -1874,9 +1922,7 @@ export function appendCollapsedChatTranscriptEvent( rows[existingIndex] = { key, timestamp: envelope.timestamp, - // Merge, don't replace: an update that omits `rows`/`metrics` is a - // partial patch, not an instruction to blank them. - event: { ...existing.event, ...event, cardId }, + event: mergeAdeCardEvent(existing.event, event, cardId), }; context?.adeCardRowIndexById.set(cardId, existingIndex); return; diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx index 7976c3541..970755742 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx @@ -1,8 +1,17 @@ import { useState } from "react"; import { motion } from "motion/react"; +import { ListChecks } from "@phosphor-icons/react"; import type { AgentChatEvent, AgentChatPlanStep } from "../../../../shared/types"; import { cn } from "../../ui/cn"; import { ChatMarkdown } from "../chatMarkdown"; +import { + CHAT_CARD_WIDTH_CLASS, + ChatCardDetail, + ChatCardDetailRow, + ChatCardRow, + ChatCardTitle, + type ChatCardTone, +} from "../chatCardPrimitives"; type PlanEvent = Extract; @@ -11,8 +20,6 @@ type CodexPlanCardProps = { onOpenInfo?: () => void; }; -const VIOLET = "#A78BFA"; - function PlanMarkdown({ markdown }: { markdown: string }) { return (
@@ -21,27 +28,12 @@ function PlanMarkdown({ markdown }: { markdown: string }) { ); } -function PlanGlyph({ status }: { status: AgentChatPlanStep["status"] }) { - if (status === "in_progress") { - return ( - - {"◐"} - - ); - } - if (status === "completed") { - return ( - {"●"} - ); - } - if (status === "failed") { - return ( - {"✕"} - ); - } - return ( - {"○"} - ); +/** Step status → the shared tone vocabulary. A failed step is amber, not red. */ +function planStepTone(status: AgentChatPlanStep["status"]): ChatCardTone { + if (status === "completed") return "ok"; + if (status === "in_progress") return "running"; + if (status === "failed") return "warn"; + return "neutral"; } export function CodexPlanCard({ event, onOpenInfo }: CodexPlanCardProps) { @@ -75,71 +67,61 @@ export function CodexPlanCard({ event, onOpenInfo }: CodexPlanCardProps) { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.14, ease: "easeOut" }} className={cn( - "relative overflow-hidden rounded-xl border border-violet-400/15 bg-violet-500/[0.025]", - onOpenInfo && "cursor-pointer transition-colors hover:border-violet-300/25 hover:bg-violet-500/[0.04] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-violet-300/45", + // Checklist shape: one head row, one detail row per step. Same + // `[glyph | content | meta]` grid and same width token as every other + // transcript card. + CHAT_CARD_WIDTH_CLASS, + "relative overflow-hidden rounded-[calc(var(--chat-radius-card)-6px)] bg-white/[0.03] px-3 py-2.5", + onOpenInfo && "cursor-pointer transition-colors hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-violet-300/45", )} > -
-

- {stateLabel} -

- {steps.length ? ( - - {steps.filter((s) => s.status === "completed").length} - / - {steps.length} - + s.status === "completed").length}/${steps.length}` : null} + > + {stateLabel} + {event.explanation ? ( +

+ {event.explanation} +

) : null} -
- - {event.explanation ? ( -

- {event.explanation} -

- ) : null} + {steps.length ? ( -
    - {steps.map((step, idx) => { - const isActive = step.status === "in_progress"; - const isComplete = step.status === "completed"; - return ( -
  • - - - - {step.text} -
  • - ); - })} -
+ + {steps.map((step, idx) => ( + + ))} + ) : !hasStreaming ? ( -
+
Drafting steps…
) : null} {showCompletedMarkdownInline ? ( -
+
) : null} {showMarkdownToggle ? ( -
+
) : content} - {proofCount > 0 ? ( + {turnProof.length > 0 ? ( ) : null} @@ -5008,6 +5016,15 @@ function DoneTurnDivider({ ) : null} + {turnProof.length > 0 && proofOpen ? ( +
+ +
+ ) : null}
); } @@ -5191,7 +5208,7 @@ type EventRowProps = { onRestoreCancelledQueue?: (recoveryId: string) => Promise; settledQueueRecoveryIds?: Set; /** Proof captured during this turn — surfaced as a chip on the turn rule. */ - turnProofCount?: number; + turnProof?: ComputerUseArtifactView[]; onOpenProofDrawer?: () => void; }; @@ -5239,7 +5256,7 @@ const EventRow = React.memo(function EventRow({ onCancelQueuedMessage, onRestoreCancelledQueue, settledQueueRecoveryIds, - turnProofCount = 0, + turnProof, onOpenProofDrawer, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) @@ -5336,7 +5353,7 @@ const EventRow = React.memo(function EventRow({ timestamp={envelope.timestamp} durationMs={turnEndDurationMs ?? null} toolEntries={turnToolEntries} - proofCount={turnProofCount} + proofArtifacts={turnProof} onOpenProofDrawer={onOpenProofDrawer} onNavigateSuggestion={onNavigateSuggestion} onInsertDraft={onInsertDraft} @@ -6136,21 +6153,23 @@ function AgentChatMessageListMain({ * from the turn that produced the capture, not a second copy of the artifacts. * Bucketing is by wall clock because artifacts carry `createdAt`, not turnId. */ - const turnProofCountByRowKey = useMemo(() => { - const map = new Map(); + const turnProofByRowKey = useMemo(() => { + const map = new Map(); if (!proofArtifacts.length) return map; - const stamps = proofArtifacts - .map((artifact) => Date.parse(artifact.createdAt)) - .filter((value) => Number.isFinite(value)) - .sort((left, right) => left - right); - if (!stamps.length) return map; + const stamped = proofArtifacts + .map((artifact) => ({ artifact, at: Date.parse(artifact.createdAt) })) + .filter((entry) => Number.isFinite(entry.at)) + .sort((left, right) => left.at - right.at); + if (!stamped.length) return map; let windowStart = Number.NEGATIVE_INFINITY; for (const env of allGroupedRows) { if (env.event.type !== "done") continue; const endMs = Date.parse(env.timestamp); if (!Number.isFinite(endMs)) continue; - const count = stamps.filter((stamp) => stamp > windowStart && stamp <= endMs).length; - if (count > 0) map.set(env.key, count); + const captured = stamped + .filter((entry) => entry.at > windowStart && entry.at <= endMs) + .map((entry) => entry.artifact); + if (captured.length > 0) map.set(env.key, captured); windowStart = endMs; } return map; @@ -6886,8 +6905,8 @@ function AgentChatMessageListMain({ const turnToolEntries = envelope.event.type === "done" ? (transcriptToolActivity.byDoneRowKey.get(envelope.key) ?? []) : undefined; - const turnProofCount = envelope.event.type === "done" - ? (turnProofCountByRowKey.get(envelope.key) ?? 0) + const turnProof = envelope.event.type === "done" + ? turnProofByRowKey.get(envelope.key) : undefined; const turnModel = currentTurn ? (turnModelState.map.get(currentTurn) ?? null) @@ -6912,7 +6931,7 @@ function AgentChatMessageListMain({ turnModel={turnModel} turnEndDurationMs={turnEndDurationMs} turnToolEntries={turnToolEntries} - turnProofCount={turnProofCount} + turnProof={turnProof} onOpenProofDrawer={onOpenProofDrawer} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} @@ -6964,7 +6983,7 @@ function AgentChatMessageListMain({ turnModel={turnModel} turnEndDurationMs={turnEndDurationMs} turnToolEntries={turnToolEntries} - turnProofCount={turnProofCount} + turnProof={turnProof} onOpenProofDrawer={onOpenProofDrawer} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} @@ -7004,7 +7023,7 @@ function AgentChatMessageListMain({ settledQueueRecoveryIds={settledQueueRecoveryIds} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey, turnProofCountByRowKey, onOpenProofDrawer]); + }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey, turnProofByRowKey, onOpenProofDrawer]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { From b003b3f0b100f4e95b65730a19e2299023c6a117 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:29 -0400 Subject: [PATCH 03/20] Address quality review: unreachable delete surface, jail escapes, silent drops The proof delete surface shipped unreachable. ade/actions/call dispatches off READ_ONLY_TOOLS/MUTATION_TOOLS, and three registered tools were in neither, so ade proof rm, prune --broken and recover all returned methodNotFound. Added them, plus a test that fails without the fix. Inline proof rendered no images: the filmstrip was mounted without a resolveThumbnailSrc, so every tile showed its kind label. Local projects now resolve previews through the ade-artifact:// project handler. Security: - Lane delete's unlink jail was a lexical prefix check. uri is CRR-replicated, so a peer can write it, and a directory symlink under the artifacts dir would let the unlink walk out. Now realpath-jailed like the broker's own resolver. - The secrets deny-list compared lexically while the allow-list beside it realpaths, so a symlink into .ade/secrets passed the barrier it exists to be. Correctness: - artifact-deleted was emitted with owner: null, which every listener drops, so deletes made outside the drawer never refreshed it. Now emitted per owner. - Batch ingest half-committed when one input was rejected; inputs resolve before any row is written. - Restored heif/tif/tiff/m4v/ogv, which the CLI kind table and the preview MIME table already supported. - browserMock lacked the four new methods, so the web renderer threw on delete. Co-Authored-By: Claude Opus 5 --- apps/ade-cli/src/adeRpcServer.test.ts | 21 ++++++++ apps/ade-cli/src/adeRpcServer.ts | 3 ++ .../computerUseArtifactBrokerService.ts | 51 +++++++++++++++++-- .../src/main/services/lanes/laneService.ts | 14 +++-- apps/desktop/src/renderer/browserMock.ts | 7 ++- .../components/chat/AgentChatMessageList.tsx | 35 +++++++++++-- 6 files changed, 118 insertions(+), 13 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index efb4a8cf5..4d23c7b1f 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1267,6 +1267,27 @@ describe("adeRpcServer", () => { }); }); + // `ade/actions/call` is the only way into `runTool`, and it dispatches off + // READ_ONLY_TOOLS / MUTATION_TOOLS. A tool registered in the inventory but + // absent from both sets is advertised and unreachable — which is how the + // whole proof delete surface (`ade proof rm|prune|recover`) shipped broken. + it("dispatches every registered computer-use mutation tool", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "chat-1", role: "agent" }); + + for (const name of [ + "delete_computer_use_artifacts", + "prune_broken_computer_use_artifacts", + "recover_computer_use_artifact", + "list_broken_computer_use_artifacts", + ]) { + const result = await callTool(handler, name, {}); + const serialized = JSON.stringify(result ?? {}); + expect(serialized).not.toContain(`Unsupported ADE action: ${name}`); + } + }); + it("caps a session-bound CTO caller and scopes lifecycle actions to its own session", async () => { await withEnv({ ADE_DEFAULT_ROLE: "cto", ADE_CHAT_SESSION_ID: undefined }, async () => { const { runtime } = createRuntime(); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 6111fcaa4..ae75dc5b7 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -1440,6 +1440,9 @@ const READ_ONLY_TOOLS = new Set([ const MUTATION_TOOLS = new Set([ "saveMemory", "create_lane", + "delete_computer_use_artifacts", + "prune_broken_computer_use_artifacts", + "recover_computer_use_artifact", "run_ade_action", "start_cli_session", "send_to_session", diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index c7931ec54..dfbe950f8 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -121,14 +121,30 @@ function isAllowedExternalArtifactSource( * store — artifacts are previewed in the renderer and synced to paired phones, * so promoting a secrets blob into one is an exfiltration path. */ +/** + * Realpaths both sides before comparing. A lexical check is bypassable with one + * `ln -s`: a directory symlink inside an allowed root that points at the secrets + * dir would fail this test and then pass the allow-list, which realpaths. The + * deny-list is the exfiltration barrier, so it has to be at least as strict as + * the check it guards. + */ function isDeniedArtifactSource(absolutePath: string, deniedRoots: string[]): boolean { - const normalized = path.resolve(absolutePath); + const normalized = realpathOrSelf(path.resolve(absolutePath)); return deniedRoots.some((root) => { - const normalizedRoot = path.resolve(root); + const normalizedRoot = realpathOrSelf(path.resolve(root)); return normalized === normalizedRoot || normalized.startsWith(normalizedRoot + path.sep); }); } +/** Realpath when the path exists; the resolved input otherwise. */ +function realpathOrSelf(candidate: string): string { + try { + return fs.realpathSync(candidate); + } catch { + return candidate; + } +} + function resolveRendererArtifactPath(rawPath: string, projectRoot: string): string { let inputPath = rawPath; if (/^ade-artifact:\/\/project(?:\/|$)/i.test(inputPath)) { @@ -251,6 +267,8 @@ function dedupeOwners(owners: ComputerUseArtifactOwner[]): ComputerUseArtifactOw const IMPORTABLE_ARTIFACT_EXTENSIONS: ReadonlySet = new Set([ "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "avif", "heic", "mp4", "webm", "mov", "avi", "mkv", + "heif", "tif", "tiff", + "m4v", "ogv", "zip", "har", "log", "txt", "md", ]); @@ -771,6 +789,15 @@ export function createComputerUseArtifactBrokerService(args: { missing.push(artifactId); continue; } + // Read the owners before the rows go, so the delete can be announced to + // the same surfaces `artifact-linked` reached. An `owner: null` event is + // silently dropped by every listener, which is why deletes made outside + // the drawer used to leave it showing a row that no longer existed. + const owners = readLinkRows([artifactId]).map((link) => ({ + kind: link.ownerKind, + id: link.ownerId, + relation: link.relation, + })); try { const filePath = resolveArtifactFilePath(record); let fileRemoved = false; @@ -796,7 +823,14 @@ export function createComputerUseArtifactBrokerService(args: { path: filePath, freedBytes, }); - emit({ type: "artifact-deleted", artifactId, at: nowIso(), owner: null }); + const deletedAt = nowIso(); + if (owners.length) { + for (const owner of owners) { + emit({ type: "artifact-deleted", artifactId, at: deletedAt, owner }); + } + } else { + emit({ type: "artifact-deleted", artifactId, at: deletedAt, owner: null }); + } } catch (error) { failed.push({ artifactId, @@ -853,10 +887,17 @@ export function createComputerUseArtifactBrokerService(args: { const owners = dedupeOwners(request.owners ?? []); const callerRoot = toOptionalString(request.callerRoot); const laneId = resolveLaneIdForOwners(owners); - const artifacts = request.inputs.map((input) => { + // Resolve every input before persisting any of them. `resolveStoredUri` + // now throws (missing file, non-importable type, denied source), and a + // half-committed batch would leave the agent's retry inserting the + // already-stored inputs a second time. + const resolved = request.inputs.map((input) => { const kind = normalizeInputKind(input); const title = toOptionalString(input.title) ?? defaultTitleForKind(kind); - const { uri, storageKind, mimeType } = resolveStoredUri(input, kind, title, callerRoot); + return { input, kind, title, stored: resolveStoredUri(input, kind, title, callerRoot) }; + }); + const artifacts = resolved.map(({ input, kind, title, stored }) => { + const { uri, storageKind, mimeType } = stored; const metadata = { ...(isRecord(input.metadata) ? input.metadata : {}), sourcePath: toOptionalString(input.path), diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index eafe70fb6..95d49f463 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import type { AdeDb } from "../state/kvDb"; import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; import { deletePullRequestRowsByIds, deletePullRequestRowsForLane } from "../prs/pullRequestRowCleanup"; -import { isWithinDir, normalizeBranchName } from "../shared/utils"; +import { isWithinDir, normalizeBranchName, resolvePathWithinRoot } from "../shared/utils"; import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase"; import { detectConflictKind } from "../git/gitConflictState"; import { shouldLaneTrackParent } from "../../../shared/laneBaseResolution"; @@ -3086,8 +3086,16 @@ export function createLaneService({ } } const absolute = path.resolve(path.isAbsolute(relative) ? relative : path.join(projectRoot, relative)); - if (!isWithinDir(artifactsDir, absolute)) continue; - paths.push(absolute); + // Realpath, not a lexical prefix check. `uri` is a CRR-replicated column, + // so a paired peer can write it; a directory symlink under the artifacts + // dir would otherwise let this unlink walk straight out of the jail. + let jailed: string; + try { + jailed = resolvePathWithinRoot(artifactsDir, absolute, { allowMissing: true }); + } catch { + continue; + } + paths.push(jailed); } return paths; }; diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 695c2ed05..b8e714133 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3751,9 +3751,14 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { computerUse: { listArtifacts: resolvedArg([]), getOwnerSnapshot: resolvedArg({} as any), - routeArtifact: resolvedArg({} as any), updateArtifactReview: resolvedArg({} as any), readArtifactPreview: resolvedArg(null), + // The drawer calls these directly; without them the standalone web + // renderer throws a TypeError on the delete and recover controls. + deleteArtifacts: resolvedArg({ deleted: [], missing: [], failed: [] } as any), + listBrokenArtifacts: resolvedArg([]), + pruneBrokenArtifacts: resolvedArg({ deleted: [], missing: [], failed: [] } as any), + recoverArtifact: resolvedArg({} as any), onEvent: () => () => {}, }, onboarding: { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 31cb3a3cb..8e8f32644 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -145,6 +145,11 @@ import { type ChatCardTone, } from "./chatCardPrimitives"; +/** True for an absolute POSIX or Windows path, which the project handler cannot serve. */ +function path_isAbsoluteLike(value: string): boolean { + return value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value); +} + /** Stable empty array so a proof-free turn never re-renders the divider. */ const EMPTY_PROOF_ARTIFACTS: ComputerUseArtifactView[] = []; @@ -4900,6 +4905,7 @@ function DoneTurnDivider({ durationMs, toolEntries, proofArtifacts, + resolveProofThumbnailSrc, onOpenProofDrawer, onNavigateSuggestion, onInsertDraft, @@ -4911,6 +4917,7 @@ function DoneTurnDivider({ durationMs: number | null; toolEntries: ChatWorkLogEntry[]; proofArtifacts?: ComputerUseArtifactView[]; + resolveProofThumbnailSrc?: (artifact: ComputerUseArtifactView) => string | null; onOpenProofDrawer?: () => void; onNavigateSuggestion?: (suggestion: OperatorNavigationSuggestion) => void; onInsertDraft?: (text: string) => void; @@ -5020,6 +5027,7 @@ function DoneTurnDivider({
@@ -5209,6 +5217,7 @@ type EventRowProps = { settledQueueRecoveryIds?: Set; /** Proof captured during this turn — surfaced as a chip on the turn rule. */ turnProof?: ComputerUseArtifactView[]; + resolveProofThumbnailSrc?: (artifact: ComputerUseArtifactView) => string | null; onOpenProofDrawer?: () => void; }; @@ -5257,6 +5266,7 @@ const EventRow = React.memo(function EventRow({ onRestoreCancelledQueue, settledQueueRecoveryIds, turnProof, + resolveProofThumbnailSrc, onOpenProofDrawer, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) @@ -5354,6 +5364,7 @@ const EventRow = React.memo(function EventRow({ durationMs={turnEndDurationMs ?? null} toolEntries={turnToolEntries} proofArtifacts={turnProof} + resolveProofThumbnailSrc={resolveProofThumbnailSrc} onOpenProofDrawer={onOpenProofDrawer} onNavigateSuggestion={onNavigateSuggestion} onInsertDraft={onInsertDraft} @@ -5759,9 +5770,7 @@ function AgentChatMessageListMain({ mosaic, scrollToRowKeyRequest, proofArtifacts = [], - // `allowLocalProofArtifactProtocol` stays on the props type but is no longer - // read here: proof left this component as a footer and comes back as an - // inline `ade_card` row, which resolves its own artifact protocol. + allowLocalProofArtifactProtocol = false, onOpenProofDrawer, }: { events: AgentChatEventEnvelope[]; @@ -6153,6 +6162,23 @@ function AgentChatMessageListMain({ * from the turn that produced the capture, not a second copy of the artifacts. * Bucketing is by wall clock because artifacts carry `createdAt`, not turnId. */ + /** + * Thumbnail source for inline proof. The stored `uri` is project-relative + * (`.ade/artifacts/...`), and the `ade-artifact://project/` handler resolves + * exactly that against the active project root — so a local project gets real + * previews synchronously, with no per-tile IPC. A remote project has no such + * handler, so tiles fall back to their kind label and the drawer (which reads + * bytes over the runtime) stays the way to view them. + */ + const resolveProofThumbnailSrc = useCallback((artifact: ComputerUseArtifactView): string | null => { + if (!allowLocalProofArtifactProtocol) return null; + const uri = artifact.uri?.trim(); + if (!uri) return null; + if (/^ade-artifact:\/\//i.test(uri)) return uri; + if (/^https?:\/\//i.test(uri) || path_isAbsoluteLike(uri)) return null; + return `ade-artifact://project/${uri.split("/").map(encodeURIComponent).join("/")}`; + }, [allowLocalProofArtifactProtocol]); + const turnProofByRowKey = useMemo(() => { const map = new Map(); if (!proofArtifacts.length) return map; @@ -6932,6 +6958,7 @@ function AgentChatMessageListMain({ turnEndDurationMs={turnEndDurationMs} turnToolEntries={turnToolEntries} turnProof={turnProof} + resolveProofThumbnailSrc={resolveProofThumbnailSrc} onOpenProofDrawer={onOpenProofDrawer} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} @@ -7023,7 +7050,7 @@ function AgentChatMessageListMain({ settledQueueRecoveryIds={settledQueueRecoveryIds} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey, turnProofByRowKey, onOpenProofDrawer]); + }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey, turnProofByRowKey, resolveProofThumbnailSrc, onOpenProofDrawer]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { From 36763ed5552b312c08f9199c6be0485e0cf6b54f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:46:01 -0400 Subject: [PATCH 04/20] Add test, mobile, TUI, CLI, and docs parity --- apps/ade-cli/README.md | 7 + apps/ade-cli/src/cli.test.ts | 26 ++- apps/ade-cli/src/cli.ts | 7 +- .../src/tuiClient/__tests__/format.test.ts | 65 +++++++ apps/ade-cli/src/tuiClient/adeCardFormat.ts | 31 ++- apps/ade-cli/src/tuiClient/format.ts | 37 +++- .../chat/chatCardPrimitives.test.ts | 3 - apps/ios/ADE/Models/RemoteModels.swift | 11 ++ apps/ios/ADE/Resources/DatabaseBootstrap.sql | 5 + apps/ios/ADE/Services/Database.swift | 24 ++- .../Work/WorkArtifactTerminalViews.swift | 176 ++++++++++++------ .../Views/Work/WorkChatRichCardViews.swift | 48 ++++- .../Work/WorkErrorAndMessageHelpers.swift | 4 + .../ios/ADE/Views/Work/WorkEventMapping.swift | 4 + apps/ios/ADE/Views/Work/WorkModels.swift | 27 ++- .../ADE/Views/Work/WorkTranscriptParser.swift | 4 + apps/ios/ADETests/ADETests.swift | 56 +++++- docs/ARCHITECTURE.md | 42 +++-- docs/features/chat/README.md | 5 +- docs/features/chat/composer-and-ui.md | 2 +- docs/features/chat/tool-system.md | 2 +- docs/features/chat/transcript-and-turns.md | 34 +++- docs/features/computer-use/README.md | 53 ++++-- docs/features/computer-use/artifact-broker.md | 117 +++++++----- docs/features/computer-use/backends.md | 39 ++-- docs/features/lanes/README.md | 6 +- docs/features/lanes/worktree-isolation.md | 14 +- .../onboarding-and-settings/README.md | 7 +- docs/features/proof.md | 74 ++++++-- docs/features/pull-requests/README.md | 10 +- docs/features/storage-and-recovery/README.md | 9 +- .../sync-and-multi-device/ios-companion.md | 12 +- 32 files changed, 741 insertions(+), 220 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index f80234d6b..bb5f88868 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -444,6 +444,13 @@ ade code ade code --embedded ade tests run --lane lane-id --suite unit --wait ade proof list --arg ownerKind=chat --arg ownerId=session-id +ade proof attach shots/result.png --caption "Checkout complete" +ade proof rm artifact-id +ade proof broken --text # list missing/unimported proof records +ade proof recover artifact-id # re-import when the original capture still exists +ade proof prune # preview broken records; does not delete +ade proof prune --broken # delete every broken proof record +ade proof actions --text # full computer_use_artifacts action inventory ade ios-sim devices --text ade --socket ios-sim apps --text ade --socket ios-sim launch --target target-id --text diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 2d07776ed..2dac03d13 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -5928,7 +5928,7 @@ describe("ADE CLI", () => { // having to guess which tree a bare "shots/proof.png" belongs to. const plan = buildCliPlan(["proof", "attach", "shots/proof.png"]); expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; + if (plan.kind !== "execute") throw new Error("Expected proof attach to produce an execute plan"); const args = plan.steps[0]?.params?.arguments as Record; expect(args.callerRoot).toBe(process.cwd()); @@ -5940,7 +5940,7 @@ describe("ADE CLI", () => { it("maps proof rm and prune --broken to the delete actions", () => { const rm = buildCliPlan(["proof", "rm", "artifact-1", "artifact-2"]); expect(rm.kind).toBe("execute"); - if (rm.kind !== "execute") return; + if (rm.kind !== "execute") throw new Error("Expected proof rm to produce an execute plan"); expect(rm.steps[0]?.params).toMatchObject({ name: "delete_computer_use_artifacts", arguments: { artifactIds: ["artifact-1", "artifact-2"] }, @@ -5948,7 +5948,7 @@ describe("ADE CLI", () => { const prune = buildCliPlan(["proof", "prune", "--broken"]); expect(prune.kind).toBe("execute"); - if (prune.kind !== "execute") return; + if (prune.kind !== "execute") throw new Error("Expected proof prune --broken to produce an execute plan"); expect(prune.steps[0]?.params).toMatchObject({ name: "prune_broken_computer_use_artifacts", }); @@ -5956,12 +5956,30 @@ describe("ADE CLI", () => { // Bare `prune` only reports; removal has to be asked for explicitly. const dryRun = buildCliPlan(["proof", "prune"]); expect(dryRun.kind).toBe("execute"); - if (dryRun.kind !== "execute") return; + if (dryRun.kind !== "execute") throw new Error("Expected bare proof prune to produce an execute plan"); expect(dryRun.steps[0]?.params).toMatchObject({ name: "list_broken_computer_use_artifacts", }); }); + it("maps proof broken and recover to artifact repair actions", () => { + const broken = buildCliPlan(["proof", "broken", "--arg", "limit=25"]); + expect(broken.kind).toBe("execute"); + if (broken.kind !== "execute") throw new Error("Expected proof broken to produce an execute plan"); + expect(broken.steps[0]?.params).toEqual({ + name: "list_broken_computer_use_artifacts", + arguments: { limit: 25 }, + }); + + const recover = buildCliPlan(["proof", "recover", "artifact-1"]); + expect(recover.kind).toBe("execute"); + if (recover.kind !== "execute") throw new Error("Expected proof recover to produce an execute plan"); + expect(recover.steps[0]?.params).toEqual({ + name: "recover_computer_use_artifact", + arguments: { artifactId: "artifact-1" }, + }); + }); + it("rejects invalid --role values", () => { expect(() => parseCliArgs(["--role", "bogus", "lanes", "list"])).toThrow( /--role must be one of/, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 1659b0e74..714e56ba4 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1935,9 +1935,14 @@ const HELP_BY_COMMAND: Record = { $ ade proof list --text List captured artifacts $ ade proof capture --caption "Done" Capture a screenshot artifact $ ade proof attach /tmp/proof.png --caption "Done" Attach an existing image/video + $ ade proof rm artifact-id Delete stored proof and its record + $ ade proof broken --text List proof whose stored file is unavailable + $ ade proof recover artifact-id Re-import a broken proof from its surviving source + $ ade proof prune Preview broken proof records (does not delete) + $ ade proof prune --broken Delete every broken proof record $ ade proof record --seconds 20 Capture a short video proof $ ade proof launch --app "ADE" Launch an app for proof capture - $ ade proof ingest --input-json '{"artifacts":[]}' Ingest external visual proof artifacts + $ ade proof ingest --input-json '{"backendStyle":"external_cli","backendName":"agent-browser","inputs":[{"kind":"screenshot","path":"/tmp/proof.png"}]}' Ingest external visual proof artifacts `, "ios-sim": `${ADE_BANNER} iOS Simulator diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index c3404060c..38c102278 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -1431,4 +1431,69 @@ describe("ade_card (TUI)", () => { const textIndex = lines.findIndex((line) => line.body.includes("meanwhile")); expect(cardIndex).toBeLessThan(textIndex); }); + + it("preserves trusted detail and labels it stale when a degraded refresh lands", () => { + const initial = card({ + rows: [{ icon: "pass", text: "lint", detail: "passed" }], + rowsTruncated: 2, + metrics: [{ label: "jobs", value: "3" }], + progress: { passed: 3, failed: 0, running: 0, queued: 0 }, + }); + const degraded = card({ + rows: [], + progress: { passed: 0, failed: 0, running: 0, queued: 0 }, + degradedReason: "HTTP 403: rate limited", + }); + + const degradedBody = renderChatLines({ + activeSession: null, + notices: [], + events: [ + env("2026-07-27T12:00:00.000Z", 1, initial), + env("2026-07-27T12:01:00.000Z", 2, degraded), + ], + }).at(-1)!.body; + expect(degradedBody).toContain("lint"); + expect(degradedBody).toContain("3 jobs"); + expect(degradedBody).toContain("+2 more"); + expect(degradedBody).toContain("detail unavailable"); + expect(degradedBody).toContain("stale"); + expect(degradedBody).toContain("HTTP 403: rate limited"); + + const recoveredBody = renderChatLines({ + activeSession: null, + notices: [], + events: [ + env("2026-07-27T12:00:00.000Z", 1, initial), + env("2026-07-27T12:01:00.000Z", 2, degraded), + env("2026-07-27T12:02:00.000Z", 3, card({ + rows: [{ icon: "pass", text: "tests", detail: "passed" }], + progress: { passed: 1, failed: 0, running: 0, queued: 0 }, + degradedReason: null, + })), + ], + }).at(-1)!.body; + expect(recoveredBody).toContain("tests"); + expect(recoveredBody).not.toContain("lint"); + expect(recoveredBody).not.toContain("stale"); + expect(recoveredBody).not.toContain("detail unavailable"); + }); + + it("renders a card's measured duration with an hour unit", () => { + const body = renderChatLines({ + activeSession: null, + notices: [], + events: [ + env("2026-07-27T12:00:00.000Z", 1, card({ + durationMs: 3_723_000, + rows: [{ icon: "file", text: "report.md" }], + rowsTruncated: 4, + })), + ], + }).at(-1)!.body; + + expect(body).toContain("ran 1h 2m"); + expect(body).toContain("report.md"); + expect(body).toContain("+4 more"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/adeCardFormat.ts b/apps/ade-cli/src/tuiClient/adeCardFormat.ts index e342d0bb2..949709fa2 100644 --- a/apps/ade-cli/src/tuiClient/adeCardFormat.ts +++ b/apps/ade-cli/src/tuiClient/adeCardFormat.ts @@ -60,6 +60,19 @@ function adeCardProgressBar(card: AdeCardPayload): string | null { return bar.slice(0, width); } +function formatCardDuration(durationMs: number | null | undefined): string | null { + if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs < 0) return null; + if (durationMs < 1000) return `${Math.max(1, Math.round(durationMs))}ms`; + const seconds = Math.round(durationMs / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m${remainingSeconds ? ` ${remainingSeconds}s` : ""}`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h${remainingMinutes ? ` ${remainingMinutes}m` : ""}`; +} + /** * Box-drawn `ade_card`, the TUI's ambient equivalent of the desktop card: * a row in the transcript, not a new pane. @@ -74,7 +87,8 @@ export function renderAdeCardBody(card: AdeCardPayload): string { const failing = (card.progress?.failed ?? 0) > 0 || (card.rows ?? []).some((row) => normalizeAdeCardTone(row.tone) === "warning") || (card.metrics ?? []).some((metric) => normalizeAdeCardTone(metric.tone) === "warning"); - const glyph = card.state === "live" ? "◐" : failing ? "✕" : "✓"; + const degradedReason = card.degradedReason?.trim() || null; + const glyph = card.state === "live" ? "◐" : failing ? "✕" : degradedReason ? "!" : "✓"; const heading = singleLine( [card.title, card.subtitle?.trim() || null].filter(Boolean).join(" · "), ADE_CARD_INNER_WIDTH - 6, @@ -90,6 +104,18 @@ export function renderAdeCardBody(card: AdeCardPayload): string { const metrics = (card.metrics ?? []).map((metric) => `${metric.value} ${metric.label}`.trim()); if (metrics.length) lines.push(adeCardBoxRow(metrics.join(" "))); + const duration = formatCardDuration(card.durationMs); + const statusMeta = [ + card.stale ? "stale" : null, + duration ? `ran ${duration}` : null, + ].filter((value): value is string => Boolean(value)); + if (degradedReason) { + lines.push(adeCardBoxRow("! detail unavailable", statusMeta.join(" · "))); + lines.push(adeCardBoxRow(singleLine(degradedReason, ADE_CARD_INNER_WIDTH - 2))); + } else if (statusMeta.length) { + lines.push(adeCardBoxRow(statusMeta.join(" · "))); + } + const rows = (card.rows ?? []).slice(0, 5); if (rows.length) { lines.push(adeCardBoxRow("")); @@ -97,6 +123,9 @@ export function renderAdeCardBody(card: AdeCardPayload): string { const rowGlyph = row.icon ? ADE_CARD_ROW_GLYPHS[row.icon] ?? "·" : "·"; lines.push(adeCardBoxRow(`${rowGlyph} ${row.text}`, row.detail?.trim() ?? "")); } + if ((card.rowsTruncated ?? 0) > 0) { + lines.push(adeCardBoxRow(`+${card.rowsTruncated} more`)); + } } if (deeplink) { diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 05a99b680..9434053e2 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { Lexer, type Token, type Tokens } from "marked"; import type { AgentChatEvent, AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; -import type { AdeCardPayload } from "../../../desktop/src/shared/adeCard"; +import { adeCardProgressTotal, type AdeCardPayload } from "../../../desktop/src/shared/adeCard"; import { renderAdeCardBody } from "./adeCardFormat"; import { highlightCode, type HighlightedToken } from "./highlightCache"; import { glyphFor } from "./theme"; @@ -41,6 +41,39 @@ export function webSearchResultDomain(url: string | undefined | null): string { } } +/** + * Repeat card emits are patches, not replacements. Match the desktop merge + * contract so a failed refresh cannot erase detail that the TUI already showed + * as trustworthy; keep it visible and label it stale until a healthy emit + * replaces it. + */ +function mergeAdeCardPayload(existing: AdeCardPayload, incoming: AdeCardPayload): AdeCardPayload { + const merged: AdeCardPayload = { ...existing, ...incoming }; + const incomingRows = incoming.rows ?? []; + const incomingMetrics = incoming.metrics ?? []; + const incomingProgressTotal = adeCardProgressTotal(incoming.progress); + const existingHadDetail = (existing.rows?.length ?? 0) > 0 + || (existing.metrics?.length ?? 0) > 0 + || adeCardProgressTotal(existing.progress) > 0; + const incomingHasDetail = incomingRows.length > 0 + || incomingMetrics.length > 0 + || incomingProgressTotal > 0; + + if (existingHadDetail && (!incomingHasDetail || incoming.degradedReason)) { + if (!incomingRows.length && existing.rows?.length) merged.rows = existing.rows; + if (!incomingMetrics.length && existing.metrics?.length) merged.metrics = existing.metrics; + if (incomingProgressTotal === 0 && existing.progress) merged.progress = existing.progress; + if (existing.rowsTruncated != null && incoming.rowsTruncated == null) { + merged.rowsTruncated = existing.rowsTruncated; + } + merged.stale = true; + return merged; + } + + if (incomingHasDetail) merged.stale = incoming.stale ?? false; + return merged; +} + export function webSearchResultPreviewLines( results: ReadonlyArray<{ title?: string; url?: string }> | undefined, resultsTotal: number | undefined, @@ -583,7 +616,7 @@ export function renderChatLines(args: { const existing = adeCardsById.get(cardId); adeCardsById.set(cardId, { firstIndex: existing?.firstIndex ?? entry.index, - card: existing ? { ...existing.card, ...event } : event, + card: existing ? mergeAdeCardPayload(existing.card, event) : event, }); continue; } diff --git a/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts index cf033700b..0aa03c193 100644 --- a/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.test.ts @@ -15,9 +15,6 @@ describe("humanizeAgentIdentity", () => { ref: "#927", raw: "/ROOT/SHIP_POLL_927", }); - }); - - it("keeps the raw value for the tooltip so nothing is lost", () => { expect(humanizeAgentIdentity("/root/review_fixer")?.raw).toBe("/root/review_fixer"); expect(humanizeAgentIdentity("/root/review_fixer")?.label).toBe("Review fixer"); expect(humanizeAgentIdentity("/root/review_fixer")?.ref).toBeNull(); diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 891284459..25a5d90f9 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2222,6 +2222,10 @@ struct AgentChatAdeCardPayload: Decodable, Equatable { var actions: [AgentChatAdeCardAction]? = nil var fallbackText: String? = nil var turnId: String? = nil + var durationMs: Int? = nil + var degradedReason: String? = nil + var stale: Bool? = nil + var rowsTruncated: Int? = nil /// Last-resort body for a payload that could not be decoded at all. static let empty = AgentChatAdeCardPayload() @@ -2229,6 +2233,7 @@ struct AgentChatAdeCardPayload: Decodable, Equatable { private enum CodingKeys: String, CodingKey { case cardId, variant, state, title, subtitle case metrics, rows, progress, navTarget, actions, fallbackText, turnId + case durationMs, degradedReason, stale, rowsTruncated } init() {} @@ -2247,6 +2252,10 @@ struct AgentChatAdeCardPayload: Decodable, Equatable { actions = try? container.decodeIfPresent([AgentChatAdeCardAction].self, forKey: .actions) fallbackText = try? container.decodeIfPresent(String.self, forKey: .fallbackText) turnId = try? container.decodeIfPresent(String.self, forKey: .turnId) + durationMs = try? container.decodeIfPresent(Int.self, forKey: .durationMs) + degradedReason = try? container.decodeIfPresent(String.self, forKey: .degradedReason) + stale = try? container.decodeIfPresent(Bool.self, forKey: .stale) + rowsTruncated = try? container.decodeIfPresent(Int.self, forKey: .rowsTruncated) } } @@ -3533,6 +3542,8 @@ struct ComputerUseArtifactSummary: Codable, Identifiable, Equatable { var storageKind: String var mimeType: String? var metadataJson: String? + /// Optional for compatibility with hosts that predate lane-scoped proof. + var laneId: String? = nil var createdAt: String var ownerKind: String var ownerId: String diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index d1d021870..9c4d486ec 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -805,6 +805,7 @@ create table if not exists computer_use_artifacts ( storage_kind text not null, mime_type text, metadata_json text not null default '{}', + lane_id text, created_at text not null, foreign key(project_id) references projects(id) ); @@ -813,6 +814,10 @@ create index if not exists idx_computer_use_artifacts_project_created on compute create index if not exists idx_computer_use_artifacts_project_kind on computer_use_artifacts(project_id, artifact_kind); +alter table computer_use_artifacts add column lane_id text; + +create index if not exists idx_computer_use_artifacts_lane on computer_use_artifacts(project_id, lane_id); + create table if not exists computer_use_artifact_links ( id text primary key, artifact_id text not null, diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 15c486b3c..f93d15549 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -123,6 +123,7 @@ final class DatabaseService { let storageKind: String let mimeType: String? let metadataJson: String? + let laneId: String? let createdAt: String let ownerKind: String let ownerId: String @@ -2162,7 +2163,7 @@ final class DatabaseService { let sql = """ select a.id, a.artifact_kind, a.backend_style, a.backend_name, a.source_tool_name, a.original_type, - a.title, a.description, a.uri, a.storage_kind, a.mime_type, a.metadata_json, a.created_at, + a.title, a.description, a.uri, a.storage_kind, a.mime_type, a.metadata_json, a.lane_id, a.created_at, l.owner_kind, l.owner_id, l.relation from computer_use_artifacts a inner join computer_use_artifact_links l on l.artifact_id = a.id @@ -2200,10 +2201,11 @@ final class DatabaseService { storageKind: stringValue(statement, index: 9) ?? "", mimeType: stringValue(statement, index: 10), metadataJson: stringValue(statement, index: 11), - createdAt: stringValue(statement, index: 12) ?? "", - ownerKind: stringValue(statement, index: 13) ?? "", - ownerId: stringValue(statement, index: 14) ?? "", - relation: stringValue(statement, index: 15) ?? "attached_to" + laneId: stringValue(statement, index: 12), + createdAt: stringValue(statement, index: 13) ?? "", + ownerKind: stringValue(statement, index: 14) ?? "", + ownerId: stringValue(statement, index: 15) ?? "", + relation: stringValue(statement, index: 16) ?? "attached_to" ) }).map { row in let reviewMetadata = decodeJson(row.metadataJson, as: ComputerUseArtifactReviewMetadata.self) @@ -2220,6 +2222,7 @@ final class DatabaseService { storageKind: row.storageKind, mimeType: row.mimeType, metadataJson: row.metadataJson, + laneId: row.laneId, createdAt: row.createdAt, ownerKind: row.ownerKind, ownerId: row.ownerId, @@ -2949,6 +2952,17 @@ final class DatabaseService { columnName: "woke_reason", definition: "text" ) + // `computer_use_artifacts` is CRR-synced. Desktop captures now stamp their + // owning lane, so the phone must know the nullable column before applying + // that changeset. Older hosts simply leave it null. + try ensureColumn( + tableName: "computer_use_artifacts", + columnName: "lane_id", + definition: "text" + ) + try exec( + "create index if not exists idx_computer_use_artifacts_lane on computer_use_artifacts(project_id, lane_id)" + ) try exec(""" create table if not exists lane_list_snapshots ( lane_id text primary key, diff --git a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift index f76b84f8a..56efe3f54 100644 --- a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift +++ b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift @@ -41,85 +41,137 @@ struct WorkArtifactView: View { let content: WorkLoadedArtifactContent? let onAppear: () -> Void let onOpenImage: (UIImage) -> Void + @State private var isExpanded = false var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 10) { - Image(systemName: workArtifactKindIcon(artifact)) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(ADEColor.accent) - .frame(width: 36, height: 36) - .background(ADEColor.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - VStack(alignment: .leading, spacing: 3) { - Text(artifact.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - .truncationMode(.tail) - Text([workArtifactKindLabel(artifact.artifactKind), relativeTimestamp(artifact.createdAt)].joined(separator: " · ")) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) + VStack(alignment: .leading, spacing: 8) { + Button { + isExpanded.toggle() + } label: { + HStack(spacing: 8) { + Image(systemName: workArtifactKindIcon(artifact)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + .frame(width: 16) + VStack(alignment: .leading, spacing: 3) { + Text(artifact.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + Text([workArtifactKindLabel(artifact.artifactKind), relativeTimestamp(artifact.createdAt)].joined(separator: " · ")) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + Spacer(minLength: 0) + compactPreview + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) } - Spacer(minLength: 0) + .frame(minHeight: 44) + .contentShape(Rectangle()) } - - Group { - switch content { - case .image(let image): - Button { - onOpenImage(image) - } label: { - Image(uiImage: image) - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) + .buttonStyle(.plain) + .accessibilityLabel("\(artifact.title), proof added") + .accessibilityHint(isExpanded ? "Collapses proof preview" : "Expands proof preview") + + if isExpanded { + Group { + switch content { + case .image(let image): + Button { + onOpenImage(image) + } label: { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 180) + .background(Color.black.opacity(0.12), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("Open artifact image \(artifact.title)") + case .video(let url): + WorkArtifactVideoPlayerView(url: url) + case .remoteURL(let url): + if artifact.artifactKind == "video_recording" { + WorkArtifactVideoPlayerView(url: url) + } else { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + ProgressView() + } .frame(height: 180) + .frame(maxWidth: .infinity) .background(Color.black.opacity(0.12), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - .buttonStyle(.plain) - .accessibilityLabel("Open artifact image \(artifact.title)") - case .video(let url): - WorkArtifactVideoPlayerView(url: url) - case .remoteURL(let url): - if artifact.artifactKind == "video_recording" { - WorkArtifactVideoPlayerView(url: url) - } else { - AsyncImage(url: url) { image in - image - .resizable() - .scaledToFit() - } placeholder: { + } + case .text(let text): + WorkStructuredOutputBlock(title: "Artifact", text: text) + case .error(let message): + WorkArtifactInlineStatus(icon: "photo", message: message, tint: ADEColor.textMuted) + case .none: + HStack(spacing: 10) { ProgressView() + Text("Loading artifact preview…") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) } - .frame(height: 180) - .frame(maxWidth: .infinity) - .background(Color.black.opacity(0.12), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ADEColor.surfaceBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } - case .text(let text): - WorkStructuredOutputBlock(title: "Artifact", text: text) - case .error(let message): - WorkArtifactInlineStatus(icon: "photo", message: message, tint: ADEColor.textMuted) - case .none: - HStack(spacing: 10) { - ProgressView() - Text("Loading artifact preview…") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(ADEColor.surfaceBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } } } - .padding(14) - .background(ADEColor.surfaceBackground.opacity(0.7), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(ADEColor.surfaceBackground.opacity(0.5), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .task { onAppear() } } + + @ViewBuilder + private var compactPreview: some View { + switch content { + case .image(let image): + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 40, height: 30) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + case .remoteURL(let url) where workArtifactIsImage(artifact): + AsyncImage(url: url) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.clear + } + .frame(width: 40, height: 30) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + case .video, .remoteURL: + Image(systemName: "play.rectangle.fill") + .foregroundStyle(ADEColor.accent) + .frame(width: 40, height: 30) + case .text: + Image(systemName: "doc.text.fill") + .foregroundStyle(ADEColor.textSecondary) + .frame(width: 40, height: 30) + case .error: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.warning) + .frame(width: 40, height: 30) + case .none: + ProgressView() + .controlSize(.mini) + .frame(width: 40, height: 30) + } + } } /// Terminal session view. Subscribes to the host PTY for `session.id`, streams diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 84749423e..6849c56e2 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -3954,6 +3954,7 @@ struct WorkAdeCardView: View { /// Amber whenever anything on the card is in a warning tone (the contract's /// failure state), emerald for a settled all-good card, accent otherwise. private var accentTone: WorkAdeCardTone { + if card.degradedReason != nil { return .warning } if let progress = card.progress, progress.failed > 0 { return .warning } if card.metrics.contains(where: { $0.tone == .warning }) || card.rows.contains(where: { $0.tone == .warning }) { @@ -4018,13 +4019,14 @@ struct WorkAdeCardView: View { // MARK: Rich body private var richBody: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { header + if card.degradedReason != nil || card.isStale == true { degradedStatus } if !card.metrics.isEmpty { metricsRow } if !visibleRows.isEmpty { detailRows } if !availableActions.isEmpty { actionsRow } } - .padding(.vertical, 10) + .padding(.vertical, 8) } private var header: some View { @@ -4042,6 +4044,11 @@ struct WorkAdeCardView: View { .truncationMode(.middle) } Spacer(minLength: 6) + if let durationMs = card.durationMs { + Text(formattedDuration(milliseconds: durationMs)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(ADEColor.textMuted) + } if let deeplink, let label = workAdeCardNavLabel(card.navTarget) { Button { onOpenDeeplink(deeplink) @@ -4061,6 +4068,22 @@ struct WorkAdeCardView: View { .padding(.horizontal, 12) } + private var degradedStatus: some View { + HStack(spacing: 5) { + Image(systemName: "exclamationmark.circle") + .font(.system(size: 9, weight: .semibold)) + Text(card.isStale == true ? "Earlier detail · refresh unavailable" : "Detail unavailable") + .font(.caption2) + .lineLimit(1) + } + .foregroundStyle(ADEColor.warning) + .padding(.horizontal, 12) + .accessibilityLabel( + card.degradedReason.map { "Card detail unavailable. \($0)" } + ?? "Card detail may be stale" + ) + } + @ViewBuilder private var statusGlyph: some View { if card.isTerminal { @@ -4096,7 +4119,22 @@ struct WorkAdeCardView: View { /// Cap the detail list: the transcript row is a summary, and the full list /// lives behind the card's nav target. private var visibleRows: [WorkAdeCardRow] { - Array(card.rows.prefix(4)) + let candidates: [WorkAdeCardRow] + if card.variant == "pr_ci" { + // Mobile's selected checks treatment is failures-only. A green run stays + // one line; failed checks earn the small amount of transcript space. + candidates = card.rows.filter { $0.icon == .fail || $0.tone == .warning } + } else { + candidates = card.rows + } + return Array(candidates.prefix(4)) + } + + private var hiddenRowCount: Int { + if card.variant == "pr_ci" { + return card.rowsTruncated ?? 0 + } + return (card.rowsTruncated ?? 0) + max(0, card.rows.count - visibleRows.count) } private var detailRows: some View { @@ -4124,8 +4162,8 @@ struct WorkAdeCardView: View { } } } - if card.rows.count > visibleRows.count { - Text("+\(card.rows.count - visibleRows.count) more") + if hiddenRowCount > 0 { + Text("+\(hiddenRowCount) more") .font(.caption2) .foregroundStyle(ADEColor.textMuted) } diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 7fddaaad2..ec4e141ae 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -1994,6 +1994,10 @@ func workAdeCardContentMergeKey(_ card: WorkAdeCardModel) -> String { card.isTerminal ? "terminal" : "live", card.title, card.subtitle ?? "", + card.durationMs.map(String.init) ?? "", + card.degradedReason ?? "", + card.isStale == true ? "stale" : "", + card.rowsTruncated.map(String.init) ?? "", card.fallbackText, ] parts.append(card.metrics.map { "\($0.label)\t\($0.value)\t\($0.tone.rawValue)" }.joined(separator: "\n")) diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index 50d8d99ed..0a431aa15 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -765,6 +765,10 @@ func makeWorkAdeCardModel(from payload: AgentChatAdeCardPayload) -> WorkAdeCardM progress: progress, navTarget: makeWorkAdeCardNavTarget(from: payload.navTarget), actions: actions, + durationMs: payload.durationMs, + degradedReason: payload.degradedReason, + isStale: payload.stale, + rowsTruncated: payload.rowsTruncated.map { max(0, $0) }, fallbackText: fallbackText, turnId: payload.turnId ) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 6b9c0d681..a0f01cde6 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -538,6 +538,10 @@ struct WorkAdeCardModel: Identifiable, Equatable { let progress: WorkAdeCardProgress? let navTarget: WorkAdeCardNavTarget? let actions: [WorkAdeCardAction] + let durationMs: Int? + let degradedReason: String? + let isStale: Bool? + let rowsTruncated: Int? /// REQUIRED on the wire; never empty here — the parser substitutes a /// generated description when an emitter sends a blank one. let fallbackText: String @@ -556,17 +560,34 @@ struct WorkAdeCardModel: Identifiable, Equatable { /// optionals only overwrite when the newer payload actually carries them, so /// a terse progress ping cannot erase rows an earlier emit established. func merging(_ incoming: WorkAdeCardModel) -> WorkAdeCardModel { + let existingHasDetail = !metrics.isEmpty + || !rows.isEmpty + || progress.map { $0.passed + $0.failed + $0.running + $0.queued > 0 } == true + let incomingHasDetail = !incoming.metrics.isEmpty + || !incoming.rows.isEmpty + || incoming.progress.map { $0.passed + $0.failed + $0.running + $0.queued > 0 } == true + let preservesEarlierDetail = existingHasDetail + && (!incomingHasDetail || incoming.degradedReason != nil) + WorkAdeCardModel( id: id, variant: incoming.variant.isEmpty ? variant : incoming.variant, isTerminal: incoming.isTerminal, title: incoming.title.isEmpty ? title : incoming.title, subtitle: incoming.subtitle ?? subtitle, - metrics: incoming.metrics.isEmpty ? metrics : incoming.metrics, - rows: incoming.rows.isEmpty ? rows : incoming.rows, - progress: incoming.progress ?? progress, + metrics: preservesEarlierDetail && incoming.metrics.isEmpty ? metrics : incoming.metrics, + rows: preservesEarlierDetail && incoming.rows.isEmpty ? rows : incoming.rows, + progress: preservesEarlierDetail && incoming.progress == nil ? progress : incoming.progress, navTarget: incoming.navTarget ?? navTarget, actions: incoming.actions.isEmpty ? actions : incoming.actions, + durationMs: incoming.durationMs ?? durationMs, + degradedReason: incomingHasDetail ? incoming.degradedReason : incoming.degradedReason ?? degradedReason, + isStale: preservesEarlierDetail + ? true + : incomingHasDetail + ? incoming.isStale ?? false + : incoming.isStale ?? isStale, + rowsTruncated: incoming.rowsTruncated ?? rowsTruncated, fallbackText: incoming.fallbackText.isEmpty ? fallbackText : incoming.fallbackText, turnId: incoming.turnId ?? turnId, timestamp: timestamp diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index a1d334d66..cc6e2bea4 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -289,6 +289,10 @@ func workAdeCardModel( progress: workAdeCardProgress(from: eventDict["progress"]), navTarget: workAdeCardNavTarget(from: eventDict["navTarget"]), actions: workAdeCardActions(from: eventDict["actions"]), + durationMs: optionalWorkInt(eventDict["durationMs"]), + degradedReason: optionalString(eventDict["degradedReason"]), + isStale: eventDict["stale"] as? Bool, + rowsTruncated: optionalWorkInt(eventDict["rowsTruncated"]).map { max(0, $0) }, fallbackText: fallbackText, turnId: turnId, timestamp: timestamp diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 2c36d5c67..aff7cb8f3 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -7389,6 +7389,7 @@ final class ADETests: XCTestCase { storage_kind text not null, mime_type text, metadata_json text not null default '{}', + lane_id text, created_at text not null ); create table if not exists computer_use_artifact_links ( @@ -7408,11 +7409,11 @@ final class ADETests: XCTestCase { ('runtime-project', '/tmp/project-one/', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T02:00:00.000Z'); insert into computer_use_artifacts ( id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, original_type, - title, description, uri, storage_kind, mime_type, metadata_json, created_at + title, description, uri, storage_kind, mime_type, metadata_json, lane_id, created_at ) values ( 'artifact-1', 'runtime-project', 'screenshot', 'manual', 'ade-cli', 'proof attach', 'screenshot', 'Runtime proof', 'Attached while the runtime project id was canonical', 'ade-artifact://project/proof.png', - 'file', 'image/png', '{}', '2026-04-22T02:05:00.000Z' + 'file', 'image/png', '{}', 'lane-proof', '2026-04-22T02:05:00.000Z' ); insert into computer_use_artifact_links ( id, artifact_id, project_id, owner_kind, owner_id, relation, metadata_json, created_at @@ -7426,10 +7427,61 @@ final class ADETests: XCTestCase { XCTAssertEqual(artifacts.map(\.id), ["artifact-1"]) XCTAssertEqual(artifacts.first?.title, "Runtime proof") + XCTAssertEqual(artifacts.first?.laneId, "lane-proof") database.close() } + func testAdeCardDecodesTruthfulDurationAndDegradedMetadata() throws { + let payload = try JSONDecoder().decode( + AgentChatAdeCardPayload.self, + from: Data(""" + { + "cardId": "ci-927", + "variant": "pr_ci", + "state": "terminal", + "title": "Checks", + "fallbackText": "Checks detail unavailable", + "rows": [{"icon": "fail", "text": "test-desktop"}], + "durationMs": 183000, + "degradedReason": "GitHub rate limited the detail request", + "stale": true, + "rowsTruncated": 7 + } + """.utf8) + ) + + let card = makeWorkAdeCardModel(from: payload) + + XCTAssertEqual(card.durationMs, 183_000) + XCTAssertEqual(card.degradedReason, "GitHub rate limited the detail request") + XCTAssertEqual(card.isStale, true) + XCTAssertEqual(card.rowsTruncated, 7) + + let recoveredPayload = try JSONDecoder().decode( + AgentChatAdeCardPayload.self, + from: Data(""" + { + "cardId": "ci-927", + "variant": "pr_ci", + "state": "terminal", + "title": "Checks", + "fallbackText": "Checks passed", + "rows": [{"icon": "pass", "text": "test-desktop"}], + "durationMs": 190000, + "stale": false, + "rowsTruncated": 0 + } + """.utf8) + ) + let recovered = card.merging(makeWorkAdeCardModel(from: recoveredPayload)) + XCTAssertEqual(recovered.rows.map(\.text), ["test-desktop"]) + XCTAssertEqual(recovered.rows.map(\.icon), [.pass]) + XCTAssertNil(recovered.degradedReason) + XCTAssertEqual(recovered.isStale, false) + XCTAssertEqual(recovered.rowsTruncated, 0) + } + func testDatabasePersistsStableSiteIdAcrossReopen() throws { let baseURL = makeTemporaryDirectory() let database = makeDatabase(baseURL: baseURL) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d37d15547..852665da8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -172,7 +172,7 @@ Scheduled work is part of that typed chat family. `ade chat scheduled-work creat Personal chat is an explicit machine-only variant of the typed chat family: `ade chat list|create|show|read|send|interrupt|archive|unarchive|delete --personal`. It connects to the running brain, invokes `personalChats.call`, and rejects lane/Linear project flags rather than falling back to `--headless` project dispatch. ADE Code remains a project Work TUI and intentionally has no personal-chat UI in this release. -**Proof subcommands** — `ade proof capture` (alias of `screenshot`), `ade proof attach `, `ade proof record`, `ade proof launch`, `ade proof interact`, `ade proof list/status/environment/ingest`. `attach` infers the artifact kind from the file extension and routes through `ingest_computer_use_artifacts` with `backendStyle: "manual"`. Capture-style commands set `preferHeadless: true` on the plan so the connection layer drops to headless mode unless `--socket` is explicitly requested. All proof subcommands accept `--owner-kind` / `--owner-id` (with `chat` and `pr` aliases) to layer an explicit owner on top of the inferred session identity. +**Proof subcommands** — `ade proof capture` (alias of `screenshot`), `ade proof attach `, `ade proof record`, `ade proof launch`, `ade proof interact`, `ade proof list/status/environment/ingest`, `ade proof rm …`, `ade proof broken`, `ade proof prune [--broken]`, and `ade proof recover `. `attach` resolves relative paths from the caller's lane worktree, infers the artifact kind from the file extension, and routes through `ingest_computer_use_artifacts` with `backendStyle: "manual"`; the broker rejects non-evidence extensions and denied/escaped sources before inserting any row. Bare `proof prune` lists broken records, while `--broken` deletes them. Capture-style commands set `preferHeadless: true` on the plan so the connection layer drops to headless mode unless `--socket` is explicitly requested. Owner-aware proof subcommands accept `--owner-kind` / `--owner-id` (with `chat` and `pr` aliases) to layer an explicit owner on top of the inferred session identity. **Bundled runtime artifacts.** Per-platform `ade-` binaries plus their native dep tarballs live under `apps/desktop/resources/runtime/`, with packaged ADE CLI resources providing the `ptyHostWorker.cjs` used by remote terminals. `release-core.yml` builds the cross-platform set, validates that every darwin/linux arm64/x64 runtime binary and native archive is present, and publishes those runtime assets plus `install.sh` and `SHA256SUMS` on the GitHub release. `bootstrapRemoteRuntime` uploads missing or hash-mismatched artifacts on first SSH connect from the desktop client. @@ -720,8 +720,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `builtInBrowser/` | `builtInBrowserService.ts`, `builtInBrowserAgentAccess.ts`, `builtInBrowserActorCapabilities.ts`, `builtInBrowserAuthentication.ts`, `builtInBrowserProfileMigration.ts`, `builtInBrowserStateStore.ts`, `builtInBrowserNavigation.ts`, `builtInBrowserPermissions.ts`, `builtInBrowserWebAuthn.ts`, `desktopBridgeServer.ts` | In-app web browser owned by the main process. Every remote-content `WebContentsView` uses the single persistent `persist:ade-browser` storage profile (`storageProfileKey: "global"`), while service keys combine the ADE window id with a project/window/personal tab-collection key so visible tabs stay independent. Project roots route project commands and scratch observations; validated personal commands retain the personal tab collection and use the channel-specific machine-local browser-observation scratch root. Neither route partitions cookies or site storage. On first use, a bounded, idempotent migration copies unexpired persistent cookies from this channel's legacy project-derived partitions into the global profile without overwriting global cookies or copying session cookies; it preserves the old partition directories because Chromium DOM storage, IndexedDB, service-worker state, and WebAuthn credentials cannot be safely merged across partitions. The bounded machine-local state store restores HTTP(S)/blank tab URLs and the active tab for each collection, but never restores agent leases, lightweight browser sessions, or synthetic session cookies. The service caps each collection at 10 tabs, routes global-session network events back to their owning collection, drives OAuth popups and downloads, and emits targeted events. HTTP/proxy authentication uses a sandboxed, local credential prompt and passes values directly to Chromium without persisting or logging them; client-certificate requests use an explicit native chooser and only accept a certificate Electron offered. Permission requests are deny-by-default, limited to managed browser web contents and secure origins, and use persisted per-origin/embedding-origin decisions with a native human prompt; only Google's `storage-access` and `top-level-storage-access` requests retain a narrow accounts-domain compatibility exception. The Browser toolbar's trusted-renderer Profile panel exposes non-secret cookie/cache/flush diagnostics and list/remove/clear controls for remembered permission decisions; these operations are not bridged to agents or unbound CLI callers. A separate non-persistent agent-access controller requires a per-chat/lane native human grant for every non-local origin and for local origins with allowed privileged permissions; cross-origin navigations and redirects are intercepted, and sensitive popups are blocked until explicitly approved. The grant follows the agent-owned tab without a timer and clears only when an explicit trusted-renderer navigation reclaims the tab. Tabs carry owner/lease metadata. ADE-launched chats receive opaque in-memory browser actor capabilities bound to their trusted chat/lane/project or personal collection. The runtime requires the token and strips caller routing; Electron validates it in the issuing process, restores only the bound scope, forces `force: false`, and separately authenticates the bridge with the desktop launch's rotating token. Agents cannot force or impersonate a takeover, read another agent's tab status, inspect global cookie-domain diagnostics, or administer permissions. Browser sessions bind one workflow to one tab. Project observations live under `.ade/cache/browser-observations/`; personal observations live under the channel user-data `browser-observations/personal/` root, which is narrowly allowlisted for proof promotion. The issuer-restored scope selects the matching independent tab collection. Navigation/protocol policy lives in `builtInBrowserNavigation.ts`; WebAuthn account selection lives in `builtInBrowserWebAuthn.ts`. | | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. | | `chat/` | `agentChatService.ts`, `promptStashService.ts`, `chatScheduledWorkScheduler.ts`, `runtimeEvents.ts`, `claudeStructuredActivity.ts`, `openCodeStructuredActivity.ts`, `codexMcpElicitation.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`), `droidSdkEventMapper.ts`, `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool with official local-store persistence, formalizes the cross-runtime event vocabulary, normalizes provider-native web/MCP/image activity into compact shared events, persists accepted/processed/unprocessed message delivery, owns the runtime-backed 20-entry desktop prompt stash, emits provider-neutral turn health/recovery/diagnostics, aggregates moderation checks quietly, handles Codex app-server MCP elicitations, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. `chat.createScheduledWork` validates a five-field cron plus a bounded prompt and writes an ADE-owned recurring or one-shot row for any chat provider runtime or ADE-tracked provider CLI. Claude remains authoritative for provider schedule tool success and canonical ids, while ADE's store is authoritative for delivery: successful `PostToolUse` mirrors `ScheduleWakeup`, every successful `CronCreate`, and `/loop` records in `kv`, and scopes Stop/SubagentStop reconciliation to the exact provider-session owner so a new session's empty snapshot preserves prior-owner rows. `durable: true` persists Claude's provider copy, but the SDK's schedule view remains advisory; ADE state wins. The SDK gets the native fire opportunity at `fireAt`; ADE's timer waits through a 90-second grace window before backstopping a skipped or unavailable provider. A native claim requires an explicit SDK cron-task start; an exact provider id wins when present, while older ambiguous task events may claim only the earliest due CronCreate-owned row and can never consume a `ScheduleWakeup` or loop. Every managed chat row that becomes due during a foreground Claude, Codex, Cursor, Droid, or OpenCode turn stays armed and retries after 20 seconds rather than entering that turn's disposable input queue; only an actual delivery advances a cron, and expiry still wins. At an idle chat boundary the scheduler sends `messageSession(kind: "wake")`. Tracked CLI rows wait for a provider-specific visible composer boundary, resume ended sessions, and retry proven pre-delivery failures without consuming the occurrence. The scheduler restores timers, coalesces missed occurrences to one late fire, applies session/global pause state, cold-starts idle chats when necessary, expires recurring crons after seven days, and emits lifecycle rows while summaries and `chat.getScheduledWorkState` expose management state. Cancellation of Claude-owned jobs routes through `CronDelete` and remains visible until provider confirmation; ADE-owned rows cancel directly. There is no scheduled-work-specific spend cap. | -| `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, compatibility review state, routing, and artifact-root-confined preview reads capped at 10 MiB), control-plane snapshot helpers, macOS capture capability descriptor, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. Direct Codex Computer Use executable resolution lives outside this folder in `main/utils/codexComputerUse.ts` because it configures provider runtimes rather than ingesting proof. | -| `proof/` | `agentBrowserArtifactAdapter.ts` | Parses agent-browser payloads into broker inputs. | +| `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `syntheticToolResult.ts` | Proof-artifact broker (batch-safe ingest, owner/lane attribution, availability, deletion/recovery, compatibility review state, and artifact-root-confined preview reads capped at 10 MiB), control-plane snapshot helpers, macOS capture capability descriptor, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` and the agent-browser manifest adapter were removed — there is no passive auto-ingest or payload-shape parser. Direct Codex Computer Use executable resolution lives outside this folder in `main/utils/codexComputerUse.ts` because it configures provider runtimes rather than ingesting proof. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | | `conflicts/` | `conflictService.ts` | Pairwise dry-merge simulation, risk matrix, proposal generation. | | `cto/` | `ctoStateService.ts`, `ctoMemoryService.ts`, `ctoPromptContent.ts`, `linearClient.ts`, `linearIssueTracker.ts`, `linearCredentialService.ts`, `linearOAuthService.ts`, `linearTokenRefresh.ts`, `linearLaneCardService.ts`, `linearLiveStatusService.ts` | CTO identity, the smart-memory file store, session logs, and the Linear read/credential/OAuth surface. `linearLaneCardService` posts the Linear attachment card and builds the cross-machine ADE deeplink that backs the card's URL; `linearLiveStatusService` is the optional launch/PR/merge status round-trip. | @@ -1085,14 +1084,18 @@ The previous control-plane model — `ComputerUsePolicy` (`off`/`auto`/`enabled` `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` is the ingest boundary. It accepts `ComputerUseArtifactInput[]` (path, remote URI, inline text, inline JSON), materializes on-disk sources into the project artifacts dir via `secureCopyFromDescriptor` (uses `O_NOFOLLOW` + atomic rename to resist symlink tricks), writes the canonical `computer_use_artifacts` row, and links to one or more owners (`lane`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`). -Allowed import roots (trust boundary): `.ade/artifacts`, `.ade/tmp`, `os.tmpdir()`, `~/.agent-browser`. Other paths are rejected. +Allowed import roots include `.ade/artifacts`, `.ade/cache`, `.ade/tmp`, +managed lane worktrees, the project root, `os.tmpdir()`, `~/.agent-browser`, +and narrowly injected runtime-owned scratch roots. `.ade/secrets` is always +denied, both allow and deny checks use real paths, and an extension allow-list +rejects project-local secrets/database/key material even though the project root +itself is trusted. Relative paths resolve from the caller's lane worktree before +the project root. Every input in a batch resolves before any row is inserted. Supporting files in the same directory: - `controlPlane.ts` — builds `ComputerUseOwnerSnapshot` (recent artifacts + activity) and `ComputerUseSettingsSnapshot` (backend readiness, capabilities) over the broker. - `localComputerUse.ts` — exports `getLocalProofCaptureCapabilities()`, a macOS-only descriptor reporting whether `screencapture`, app launch, and GUI-interaction commands are available. -- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` — parses agent-browser payloads into `ComputerUseArtifactInput[]`. - Direct Codex Computer Use is a separate execution path. On macOS, `apps/desktop/src/main/utils/codexComputerUse.ts` requires an explicit `computer-use@openai-bundled` plugin or `mcp_servers.computer_use` opt-in, @@ -1108,22 +1111,35 @@ Canonical proof kinds: `screenshot`, `video_recording`, `browser_trace`, `browse Canonical tables: -- `computer_use_artifacts` — proof kind, backend name/style, source tool metadata, title/description, URI, storage kind, MIME type, review/workflow state, timestamps. -- `computer_use_artifact_links` — cross-domain ownership, so the same artifact can graduate from exploratory chat evidence to a PR comment without losing provenance. +- `computer_use_artifacts` — proof kind, backend name/style, source tool metadata, title/description, URI, storage kind, MIME type, optional lane id, compatibility review/workflow state, timestamps. +- `computer_use_artifact_links` — cross-domain ownership supplied at ingest. The removed `routeArtifact` mutation no longer appends owners later. + +File-backed views derive `available`, `missing_file`, or `unimported` +availability. Broker deletion is idempotent and can remove selected artifacts, +all broken records, lane-attributed proof during destructive lane teardown, all +project proof during local-data reset, or rows below a Settings storage-cleanup +path. Files are unlinked only after realpath confinement to the artifact store; +archive remains non-destructive. ### 12.4 IPC + UI Channels (under `ade.proof.*`, renamed from `ade.computerUse.*`): - `ade.proof.listArtifacts`, `ade.proof.getOwnerSnapshot`, `ade.proof.deleteArtifacts`, `ade.proof.listBrokenArtifacts`, `ade.proof.pruneBrokenArtifacts`, `ade.proof.recoverArtifact`, `ade.proof.updateArtifactReview`, `ade.proof.readArtifactPreview`, plus a `ade.proof.event` push channel. -- `ade proof capture` / `attach` / `list` in the ADE CLI are the cross-process surface; they call into the broker. +- `ade proof capture` / `attach` / `list` / `rm` / `broken` / `prune` / + `recover` in the ADE CLI are the cross-process surface; they call into the + broker. Renderer surfaces: -- `ChatComputerUsePanel` supplies the transcript-tail proof collection, in-app - image lightbox, and complete chat drawer. Local media uses the range-capable - artifact protocol; remote-runtime media uses bounded `readArtifactPreview` - responses. +- `AgentChatMessageList` buckets artifacts by capture time into the completed + turn that produced them. The turn rule exposes a collapsed proof count and + expands `ChatProofFilmstrip` in chronology; proof is never a thread-tail + footer. +- `ChatComputerUsePanel` supplies the in-app image lightbox and complete chat + drawer, including availability states and irreversible deletion. Local media + uses the range-capable artifact protocol; remote-runtime media uses bounded + `readArtifactPreview` responses. - Chat and iOS proof surfaces are collection views, not review workflows. Broker review/workflow fields remain available for compatibility with downstream integrations but are not user-facing controls. diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 85b8c442b..63496f395 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -85,8 +85,11 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, `isDraftLaunchJobStale`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. Also owns the launch durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout(promise, label)` (rejects a launch step whose runtime call never settles; the underlying IPC is not cancellable, so on timeout it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Shared renderer helper for in-flight chat handoff placeholders. Defines the handoff job DTO, scope keying, mode-aware status labels (`preparing-summary` for brief, `forking-history` for fork), search matching, the stable placeholder id used by the Work session sidebar, and `handoffJobLikelyMaterialized` — the ADE-122 dedupe that hides a placeholder as soon as a matching real session row (same lane + tool type, started at/after the job began) is visible, so an in-flight handoff never reads as two new sessions with one vanishing. | | `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled`, `launchPromptClipboardNoticeEnabled`, and the default-on `promptStashButtonEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` (+ `setDraftLaunchJobs`) for Work draft launch status strips plus `handoffLaunchJobsByScope` (+ `setHandoffLaunchJobs`) for Work sidebar handoff placeholders. These live in the **root** store (not the per-project store) on purpose: in-flight launches must survive a remote project switch that destroys the originating per-project store; `AgentChatPane` reads them via `useRootAppStore` / `rootAppStoreApi.getState()`. | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Workspace paths in Markdown links and inline code render with an explicit file glyph/click treatment and navigate with a relative path + lane id; `FilesTab` resolves that target against the active runtime's workspace roster, so the same click opens the correct file for local and remote-bound desktop projects without treating a remote path as a local OS file. Activity bundles fold todo/scheduled updates and placeholder subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline; real subagents instead render as inline `SubagentSpawnCard` / `SubagentResultCard` rows anchored where they started and settled (background shell commands collapse to a single `BackgroundFinishChip`), with jump-to-result / jump-to-start affordances that reuse the stable-row scroll machinery; a run of two or more interrupt-stopped subagents folds into one calm `SubagentStoppedGroupCard` instead of a wall of identical stopped cards. Synthetic scheduled turns render an amber `Woke on schedule` divider with fire time, reason, and late marker; the existing stable-row scroll machinery accepts jump requests from the while-you-were-away strip. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. History seeded into a forked chat (envelopes tagged `providerOrigin: "handoff_fork"`) renders under a single `Forked from the previous chat — full history above` divider pinned to the first live row after the seeded tail, rather than a per-row marker. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Workspace paths in Markdown links and inline code render with an explicit file glyph/click treatment and navigate with a relative path + lane id; `FilesTab` resolves that target against the active runtime's workspace roster, so the same click opens the correct file for local and remote-bound desktop projects without treating a remote path as a local OS file. Activity bundles fold todo/scheduled updates and placeholder subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline; real subagents instead render as inline `SubagentSpawnCard` / `SubagentResultCard` rows anchored where they started and settled (background shell commands collapse to a single `BackgroundFinishChip`), with jump-to-result / jump-to-start affordances that reuse the stable-row scroll machinery; a run of two or more interrupt-stopped subagents folds into one calm `SubagentStoppedGroupCard` instead of a wall of identical stopped cards. Synthetic scheduled turns render an amber `Woke on schedule` divider with fire time, reason, and late marker; the existing stable-row scroll machinery accepts jump requests from the while-you-were-away strip. Completed-turn dividers bucket chat-owned proof by capture timestamp, expose a collapsed `N proof` chip, and expand the filmstrip directly beneath the producing turn; there is no proof footer pinned to the tail. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. History seeded into a forked chat (envelopes tagged `providerOrigin: "handoff_fork"`) renders under a single `Forked from the previous chat — full history above` divider pinned to the first live row after the seeded tail, rather than a per-row marker. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. | | `apps/desktop/src/renderer/components/chat/chatHistoryWindow.ts` | Shared bounded-history policy for project and personal desktop chats: canonical event identity, byte estimates and resident caps, page-seam merging, strict cursor advancement, bounded continuation through empty physical pages, and stale-request predicates. Snapshot cursor reconciliation preserves a known exhausted head only when the authoritative refresh overlaps the current window and retains its oldest event; replacement snapshots and cap eviction re-arm paging. | +| `apps/desktop/src/renderer/components/chat/chatAppearance.ts` | Chat density/font geometry plus the single responsive transcript width contract. `--chat-content-width` is `min(100%, clamp(720px, 62vw, 1180px))`; `--chat-column` aliases it so prose, composer, cards, pills, plans, file changes, and floating-pane reserve math share one viewport-scaling measure. | +| `apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx` | Shared transcript-card vocabulary: one `[16px glyph | flexible content | auto meta]` grid, line/inset/bordered/rail/plain skins, status tones, chips/meters/detail rows/diff stats, human-readable agent identity and schedule formatting, and the collapsible proof filmstrip. Passing one-line facts use a hairline row; live/detail-bearing rows use inset chrome; failures use an amber rail rather than a red block. | +| `apps/desktop/src/renderer/components/chat/AdeCard.tsx` | Provider-independent `ade_card` renderer built only from the shared primitives. Shape follows state rather than variant: terminal success is one line, live work adds progress, failures show only warning rows, unknown variants fall back to text + deeplink, and degraded re-emits preserve prior detail as stale rather than blanking it. | | `apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx` | Inline subagent transcript cards mounted by `AgentChatMessageList` from the render events `chatTranscriptRows.ts` derives. `SubagentSpawnCard` anchors where the agent started (identicon/colour from `chatSubagentIdentity`, task title, agent-type/background chips, a single live `running · · tools · ` line that ticks each second, and a `jump to result` link once the agent ends); `SubagentResultCard` renders at the settle position (status + duration, ~2-line report preview, View transcript, `jump to start`, warm amber tones for stopped/failed instead of red error blocks); `BackgroundFinishChip` is the one-line finish chip for backgrounded shell commands; `SubagentStoppedGroupCard` collapses a run of interrupt-stopped subagents into one amber "N agents stopped when you interrupted" line that expands to a per-agent list with `jump to start` links. All inherit `--chat-accent`. | | `apps/desktop/src/renderer/components/chat/spawnNavigation.ts` | One canonical `navigateToSpawnedChat(sessionId, laneId?)` helper that dispatches the `ade:work:select-session` window event (behind a try/catch, no-op on a falsy id). Every spawn surface routes through it: the inline `SubagentSpawnCard`, `spawn_wake_divider` and `spawn_completed` completion rows, spawned-chat rows in `ChatSubagentsPanel`, the `AgentChatPane` parent-thread breadcrumb, and the `SessionCard` lineage glyph. `TerminalsPage` resolves an omitted lane from the loaded session list before focusing the target, so cross-lane jumps land on the correct lane. | | `apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Codex Chat Actions source inventory. Sources is the first available tab and derives a deduplicated list of attachments/files, web searches/results, MCP apps/tools, and external resource URLs from the current transcript. HTTP(S) rows open in ADE's built-in browser; internal `node_repl` plumbing and unsafe protocols are excluded. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index a0b41c71a..6d45d2e0d 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -39,7 +39,7 @@ subagents, computer use). The pane derives all visible state from the | `ChatFileChangesPanel.tsx` | Turn-level file change summary with lazy diff expansion. | | `RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Undo confirmation for provider-backed file rewind. Builds a message-scoped file list from provider dry-run output plus turn diff summaries, then renders per-file expandable diffs before applying `rewindFiles`. Claude uses SDK file checkpoints; Codex forks the thread before the selected turn (`thread/fork` + `beforeTurnId`) on app-server >= 0.145.0, or falls back to `thread/rollback` (latest user message only) on older servers, and restores files through ADE's git plan. | | `ChatSubagentsPanel.tsx` | Chat Info panel. It renders the Codex goal card, latest plan, tasks, schedule, and subagent/background rosters. Running subagent and background rows derive elapsed time from the wall clock and tick once per second; terminal rows keep their final compact duration. Large sections cap active rows and add Show all; terminal rows move into one Completed fold; Clear/Restore is a visual per-session filter. Failed and pinned rows remain active, survivors keep source order, and the pane variant owns a single scroller with sticky section headers. Spawned-chat rows are identified by `childSessionId`, show the live child title supplied by `AgentChatPane` / `WorkViewArea`, keep the runtime as a small kind chip, and navigate to the child rather than opening the provider-subagent drawer. The Schedule header keeps the per-chat pause/play action. For Codex sessions the goal card stays above plan/subagent progress so the current objective stays visible without crowding the chat header. | -| `ChatComputerUsePanel.tsx` | Shared proof cards for the newest six transcript-tail items and the complete chat drawer. Images open in an in-app lightbox, video renders inline, preview reads stay runtime-routed for remote projects, and the surface has no review or Finder/reveal controls. | +| `ChatComputerUsePanel.tsx` | Complete chat proof drawer with image lightbox, inline video, availability/error states, and irreversible artifact deletion. Preview reads stay runtime-routed for remote projects, and the surface has no review or Finder/reveal controls. Inline transcript proof is owned by `AgentChatMessageList` + `ChatProofFilmstrip`: a collapsed count on the producing turn expands in chronology instead of pinning the newest items to the thread tail. | | `ChatAppControlPanel.tsx` | App Control panel for Electron apps. Two mount points: under the chat composer (chat-scoped, `sessionId` set) and inside the Work right-edge sidebar (lane-scoped, `sessionId={null}`). Two modes: **Control** (live screencast frames + launch/connect form + click/type input + quick `terminal write` / `terminal signal` actions) and **Inspect** (hit-test crosshair on the screenshot; commits selections as `AppControlContextItem`s with screenshot, DOM packet, and source-file candidates). Persists panel state under `sessionStorage["ade.chat.appControlPanel."]`, where the key is `chat:` for the chat mount and `lane::` for the sidebar mount. Connect/launch calls forward `laneId` so the resulting `AppControlSession` records its launching lane. See [App Control](../computer-use/app-control.md). | | `ChatIosSimulatorPanel.tsx` | macOS-only iOS Simulator drawer. Two mount points: under the chat composer and inside the Work right-edge sidebar. Tool-readiness checklist, device + target pickers, three-backend live preview, `interact` vs `inspect` mode, hit-test overlay, and selection emission as `IosElementContextItem`. Accepts an optional `laneId` prop, forwarded into `iosSimulator.launch` so the resulting `IosSimulatorSession` records its launching lane. Simulator controls are not blocked when another chat session owns the simulator — ownership only affects which session receives context insertions, not whether the user can interact with the device. See [iOS Simulator feature](../ios-simulator/README.md). | | `ChatBuiltInBrowserPanel.tsx` | In-app browser panel mounted under the Work right-edge sidebar's `browser` tab. Renders the address bar, navigation/tab strip, inspect toolbar, screenshot capture, and an empty/error state derived from `BuiltInBrowserStatus`; the actual page content is painted by a main-process `WebContentsView` whose bounds the panel reports back to the broker via `ade.builtInBrowser.setBounds`. Inspect-mode hit-tests emit `BuiltInBrowserContextItem` payloads through `onAddContext`; the sidebar then dispatches `ade:agent-chat:add-builtin-browser-context` to the active chat. The panel does not run inside `AgentChatPane` directly — instead, anywhere in the renderer that wants to open a URL calls `openUrlInAdeBrowser()` (in `apps/desktop/src/renderer/lib/openExternal.ts`), which fires `ADE_OPEN_BUILT_IN_BROWSER_EVENT` and asks the broker to open a new tab. | diff --git a/docs/features/chat/tool-system.md b/docs/features/chat/tool-system.md index aba3900ca..78496a900 100644 --- a/docs/features/chat/tool-system.md +++ b/docs/features/chat/tool-system.md @@ -108,7 +108,7 @@ chat gets these four tools in its palette. ### Proof capture `captureScreenshot` files the resulting image through -`computerUseArtifactBrokerService.ingestArtifacts()`. It is not gated by +`computerUseArtifactBrokerService.ingest()`. It is not gated by a policy — the proof-observer model (and `ComputerUsePolicy`) was removed. The tool still reports `blocked_by_capability` when it runs on a platform without a supported capture backend (Linux/Windows); the diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index 08c230380..fcff8fc97 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -254,6 +254,13 @@ implements a two-layer transform: that omits `rows`/`metrics` patches rather than blanks them — which keeps the row at its original position and preserves the virtualizer's measured height as a long-running card (CI, a build, an artifact pull) progresses. + When a detail refresh fails, `degradedReason` does not destructively + replace an earlier rich payload: rows/progress/metrics survive with + `stale: true`, and a first-time empty failure renders `detail unavailable` + plus its retry action instead of a false-green zero-count card. Emitters + may provide `durationMs` only for a real measured run; the renderer labels + the card update span as `tracked` so it is never presented as work time. + `rowsTruncated` becomes a compact `+N more` summary. It is deliberately NOT activity: it is never classified by `classifyActivityPhaseRow` and never bundled, so an interleaved reasoning/work phase cannot swallow it. The payload contract and its @@ -409,10 +416,29 @@ not `@tanstack/react-virtual`. The pieces: above the viewport changed height so the visible content stays still. Below `VIRTUALIZATION_THRESHOLD` rows the list skips all of this and -renders every row directly. Notable rendering rules: - -- Assistant message cards constrain to `max-w-[78ch]` for readability. -- Turn dividers (`ChatTurnDivider`) separate consecutive turns. +renders every row directly. The transcript has one responsive content-width +contract: `chatAppearance.ts` publishes `--chat-content-width` as +`min(100%, clamp(720px, 62vw, 1180px))` and aliases the older +`--chat-column` variable to it. Prose, composer, cards, plans, file changes, +activity details, and pills all use that token, while the JS +`resolveChatContentWidthPx()` mirror supplies floating-pane layout math. + +Card rows compose `chatCardPrimitives.tsx`: a fixed 16 px glyph column, +flexible title/content column, and auto-sized meta/action column. Passing +one-line facts use a hairline row, live/detail-bearing work uses an inset, and +failures use an amber rail. The same primitives back `AdeCard`, +`CodexPlanCard`, files changed, tool/work rows, and settled subagent rows. + +Notable rendering rules: + +- Assistant messages and transcript cards share the responsive content width. +- Completed-turn dividers show local time and measured duration. Tool activity + and proof each have independent collapsed controls. Chat-owned proof is + bucketed into turns by `artifact.createdAt`; expanding `N proof` renders the + horizontal `ChatProofFilmstrip` immediately below that divider. The filmstrip + is chronological, starts collapsed, and never moves to a pinned thread + footer. Local project-relative URIs render through the artifact protocol; + remote items fall back to their kind label and open the runtime-backed drawer. - Code blocks in assistant messages render through `HighlightedCode`. - User messages animate in with a `motion/react` spring transition, and over-long ones (>600 chars or >8 lines) collapse behind a CSS gradient diff --git a/docs/features/computer-use/README.md b/docs/features/computer-use/README.md index 799887f1e..2e0dc7e0a 100644 --- a/docs/features/computer-use/README.md +++ b/docs/features/computer-use/README.md @@ -13,7 +13,7 @@ See [`../proof.md`](../proof.md) for the user-facing CLI surface (`ade proof cap ## Runtime ownership -The artifact broker is owned by the ADE runtime that owns the project. Ingest, link, list, review, route, backend status, and event emission all happen inside `ade serve` for that project. Artifacts live under that runtime's `.ade/artifacts/computer-use/` directory: +The artifact broker is owned by the ADE runtime that owns the project. Ingest, link, list, delete, broken-record audit/prune/recovery, review compatibility updates, backend status, and event emission all happen inside `ade serve` for that project. Artifacts live under that runtime's `.ade/artifacts/computer-use/` directory: - **Local runtime:** artifacts on the user's machine, under the local project root. - **Remote runtime:** artifacts on the remote host, under the remote project root. The desktop renderer reads previews through `ade.proof.readArtifactPreview` over the same SSH-tunneled JSON-RPC that backs the rest of the remote project surface; raw artifact bytes are not synced back to the desktop machine. @@ -30,13 +30,9 @@ the remote host. ### Services (apps/desktop/src/main/services/computerUse/) - `computerUseArtifactBrokerService.ts` — the broker. Canonical storage for `computer_use_artifacts` + `computer_use_artifact_links`. Ingestion (`ingest`), listing (`listArtifacts`), deletion (`deleteArtifacts`, `deleteArtifactsForLane`, `pruneBrokenArtifacts`, `purgeArtifactRecordsUnder`), recovery (`recoverArtifact`), broken-record reporting (`listBrokenArtifacts`), compatibility review-state management (`updateArtifactReview`), backend status (`getBackendStatus`), and bounded preview reads (`readArtifactPreview`, 10 MiB maximum). Image previews cover BMP/GIF/JPEG/PNG/SVG/WebP; video previews cover M4V/MOV/MP4/OGV/WebM. Uses `secureCopyFromDescriptor` (O_NOFOLLOW + atomic rename) for on-disk ingests and materializes inline text/JSON content via `createComputerUseArtifactPath` + `writeTextAtomic`. -- `controlPlane.ts` — builds `ComputerUseOwnerSnapshot` (recent artifacts + activity) and `ComputerUseSettingsSnapshot` (backend readiness, capabilities). Pure assembly layer over the broker. +- `controlPlane.ts` — builds `ComputerUseOwnerSnapshot` (owner-scoped artifacts, latest active backend, summary, and artifact-derived activity) over the broker. It does not synthesize timestamped readiness activity. - `localComputerUse.ts` — macOS-only capability descriptor (`LocalComputerUseCapabilities`). Reports whether `screencapture`, app launch, and GUI-interaction commands are available. `createComputerUseArtifactPath` + `toProjectArtifactUri` round out the storage helpers. -### Proof adapters - -- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` — parses agent-browser payload shapes (screenshots, videos, traces, verification, console logs) into `ComputerUseArtifactInput[]`. - ### Direct Codex Computer Use - `apps/desktop/src/main/utils/codexComputerUse.ts` — resolves the standalone `SkyComputerUseClient`, requires explicit user opt-in, verifies its strict macOS code signature plus OpenAI team/bundle identifiers, and returns the MCP launch config. @@ -64,15 +60,18 @@ Channel constants live under `ade.proof.*` (renamed from the old `ade.computerUs Each channel routes renderer → preload → ADE runtime → broker. For local projects the preload bridge talks to the local `ade serve`; for remote projects it tunnels the same JSON-RPC payload over the SSH connection in `apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts`. The broker on the receiving runtime executes the action and emits `ade.proof.event` back along the same channel. -The `ade-cli` headless surface registers the same broker and exposes the equivalent JSON-RPC tools (`screenshot_environment`, `record_environment`, `ingest_computer_use_artifacts`, `list_computer_use_artifacts`) via `apps/ade-cli/src/adeRpcServer.ts`, so a chat agent's `ade proof capture` and the desktop renderer's transcript/drawer collections go through the same broker instance. +The `ade-cli` headless surface registers the same broker and exposes the equivalent JSON-RPC tools (`screenshot_environment`, `record_environment`, `ingest_computer_use_artifacts`, `list_computer_use_artifacts`, `delete_computer_use_artifacts`, `list_broken_computer_use_artifacts`, `prune_broken_computer_use_artifacts`, `recover_computer_use_artifact`) via `apps/ade-cli/src/adeRpcServer.ts`, so a chat agent's `ade proof capture` and the desktop renderer's transcript/drawer collections go through the same broker instance. ### Renderer - `apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx` — shared - proof card, transcript-tail collection, in-app lightbox, and full drawer for - the active chat session. Local files use ADE's range-capable artifact protocol; - remote files use `ade.proof.readArtifactPreview`. Neither path falls back to - Finder. + proof card, in-app lightbox, full drawer, availability/error states, and + irreversible delete action for the active chat session. Local files use ADE's + range-capable artifact protocol; remote files use + `ade.proof.readArtifactPreview`. Neither path falls back to Finder. +- `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx`, + `chatCardPrimitives.tsx` — bucket artifacts by capture time into the completed + turn that produced them and render the collapsed inline filmstrip. - `apps/desktop/src/renderer/lib/computerUse.ts`, `renderer/lib/proof.ts` — renderer helpers that call `window.ade.proof.*`. `ComputerUseSection.tsx` (Settings > Computer Use) was removed in this rebuild; its readiness display was folded into `IntegrationsSettingsSection`. @@ -81,7 +80,7 @@ The `ade-cli` headless surface registers the same broker and exposes the equival `ComputerUseArtifactRecord` in `computer_use_artifacts`: -- `id`, `artifact_kind`, `backend_style`, `backend_name`, `source_tool_name`, `original_type`, `title`, `description`, `uri`, `storage_kind`, `mime_type`, `metadata_json`, `created_at`. +- `id`, `artifact_kind`, `backend_style`, `backend_name`, `source_tool_name`, `original_type`, `title`, `description`, `uri`, `storage_kind`, `mime_type`, `metadata_json`, optional `lane_id`, `created_at`. `ComputerUseArtifactLink` in `computer_use_artifact_links`: @@ -105,25 +104,43 @@ Canonical `ComputerUseArtifactKind` values: ## Ingestion pipeline -`computerUseArtifactBrokerService.ingestArtifacts({ inputs, owners, backend, sourceToolName? })`: +`computerUseArtifactBrokerService.ingest({ inputs, owners, backend, callerRoot? })`: 1. Dedupe owners by `kind:id:relation`. -2. For each input, resolve storage: path (validated against the allowed-roots list), remote URI (http(s)), inline text, inline JSON. +2. Resolve every input before writing any row. Relative paths try the caller's + lane-worktree root before the project root; a missing or invalid member + rejects the whole batch. 3. Materialize inline content via `createComputerUseArtifactPath` + `writeTextAtomic`. -4. For on-disk sources, copy into the project artifacts dir via `secureCopyFromDescriptor` (O_NOFOLLOW + atomic rename to resist symlink tricks). -5. Insert the canonical record + all owner links. -6. Emit a `ComputerUseEventPayload` on `ade.proof.event`. +4. For on-disk sources, realpath-check the allow/deny roots, enforce the + evidence-extension allow-list, and copy into the project artifacts dir via + `secureCopyFromDescriptor` (`O_NOFOLLOW` + atomic rename). +5. Resolve optional `lane_id` from a lane owner or owning chat. +6. Insert the canonical record + all owner links. +7. Emit `artifact-ingested` / `artifact-linked` payloads on `ade.proof.event`. Allowed import roots (the trust boundary for external file paths): ``` layout.artifactsDir // .ade/artifacts +layout.cacheDir // .ade/cache layout.tmpDir // .ade/tmp +layout.worktreesDir // managed lane worktrees +projectRoot // captures written beside project source os.tmpdir() // OS temp ~/.agent-browser // agent-browser's output dir ``` -Other paths are rejected. +Runtime-owned callers can add explicit trusted roots; `.ade/secrets` is always +denied. Project-local `.env`, database, key, and certificate files remain +rejected by the extension gate even though `projectRoot` is allowed. + +`ComputerUseArtifactView.availability` is `available`, `missing_file`, or +`unimported` (optional for older hosts). Broken records can be listed, pruned, +or recovered through the typed CLI/action surface. Deletion removes records and +only files that resolve inside the artifact jail. Destructive lane deletion +removes lane-attributed proof unless another lane's chat owns it; archive does +not. Settings proof cleanup and project-local-data reset remove matching rows +with the bytes. ## What the rebuild removed diff --git a/docs/features/computer-use/artifact-broker.md b/docs/features/computer-use/artifact-broker.md index 11f95c316..692569ed7 100644 --- a/docs/features/computer-use/artifact-broker.md +++ b/docs/features/computer-use/artifact-broker.md @@ -1,15 +1,14 @@ # Computer-Use Artifact Broker -The broker is the normalization layer after external computer-use execution has happened. External tools perform the actual clicks, keystrokes, and captures. The broker ingests their output, stores it canonically, links it to owners (runs, chats, PRs, Linear issues), and tracks review and publication state. +The broker is the normalization layer after external computer-use execution has happened. External tools perform the actual clicks, keystrokes, and captures. The broker ingests their output, stores it canonically, links it to owners (runs, chats, PRs, Linear issues), reports whether the stored bytes still exist, and owns deletion/recovery. The broker runs inside the ADE runtime (`ade serve`) that owns the project. Artifacts are written to that runtime machine's `.ade/artifacts/computer-use/` directory; database rows live in that runtime's `.ade/ade.db`. Renderer reads/writes flow through `window.ade.proof.*` → preload → runtime JSON-RPC → broker; the desktop main process is no longer the owner of this state. ## Source file map - `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` — the service. `createComputerUseArtifactBrokerService(args)` is the entry point. Loaded by both the ADE runtime's project scope and the desktop's local-project services. `readArtifactPreview` serves only files inside the artifact root, caps data-URL responses at 10 MiB, and recognizes common image plus M4V/MOV/MP4/OGV/WebM video extensions. -- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` — payload parser for agent-browser output. - `apps/desktop/src/main/services/computerUse/localComputerUse.ts` — storage helpers (`createComputerUseArtifactPath`, `toProjectArtifactUri`). -- `apps/desktop/src/shared/types/computerUseArtifacts.ts` (via `shared/types`) — `ComputerUseArtifactRecord`, `ComputerUseArtifactLink`, `ComputerUseArtifactInput`, `ComputerUseArtifactOwner`, `ComputerUseArtifactReviewState`, `ComputerUseArtifactWorkflowState`, `ComputerUseEventPayload`. +- `apps/desktop/src/shared/types/computerUseArtifacts.ts` (via `shared/types`) — artifact/link/input/owner records plus availability, delete, broken-record, recovery, and event contracts. - `apps/desktop/src/shared/proofArtifacts.ts` — `normalizeComputerUseArtifactKind`, `resolveReportArtifactKind`. The passive `proofObserver.ts` was deleted with the rebuild; nothing watches tool results to auto-ingest captures any more. Captures are intentional: an agent or operator runs `ade proof capture/attach` (or the corresponding RPC tool) and the broker ingests once. @@ -22,15 +21,16 @@ Stored as `StoredArtifactRow`: - `id` — UUID. - `artifact_kind` — one of `screenshot`, `video_recording`, `browser_trace`, `browser_verification`, `console_logs`. -- `backend_style` — `ade-cli` | `cli` | `local`. +- `backend_style` — `external_cli` | `manual` | `local_fallback`. - `backend_name` — human-readable backend name (e.g. `"Ghost OS"`, `"agent-browser"`, `"ADE local"`). - `source_tool_name` — the tool or command that produced the artifact (e.g. `"ghost_screenshot"`, `"screenshotPath"`). - `original_type` — original kind hint from the source (for traceability). - `title`, `description`. -- `uri` — storage URI: project-relative artifact path, `http(s)://` URL for remote artifacts, or raw external path for unresolved files. +- `uri` — project-relative artifact URI or `http(s)://` URL. New ingestion never persists unresolved external paths. - `storage_kind` — `file` | `url`. - `mime_type` — optional. - `metadata_json` — backend-specific extras. +- `lane_id` — optional lane binding resolved from an explicit lane owner or an owning chat session; used by lane deletion. - `created_at` — ISO timestamp. ### `computer_use_artifact_links` @@ -41,7 +41,7 @@ Stored as `StoredLinkRow`: - `artifact_id` — FK to the artifact. - `owner_kind` — one of `lane`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`. - `owner_id` — the owner's id. -- `relation` — default `attached_to`; can also be `produced_by`, `referenced_by`, `published_to`, etc. +- `relation` — `attached_to` (default), `produced_by`, or `published_to`. - `metadata_json` — per-link metadata. - `created_at`. @@ -56,27 +56,32 @@ A single artifact can have multiple links — evidence that starts in a chat can - `kind` — explicit kind or null to infer. - `title`, `description` — optional metadata. - `path` — local file path. -- `uri` — alternate URI (http/https, file:// ok). +- `uri` — alternate HTTP(S) URI, or a path-like value handled like `path`. - `text` — inline text (for console logs, verifications). - `json` — inline JSON (serialized to file at ingestion). - `mimeType` — optional. - `rawType` — backend-specific type hint used by `normalizeComputerUseArtifactKind`. - `metadata` — arbitrary per-input metadata. -### Ingestion flow (`ingestArtifacts`) +### Ingestion flow (`ingest`) 1. **Dedupe owners** via `dedupeOwners` — unique by `kind:id:relation`. -2. For each input: +2. **Resolve every input before inserting any row.** Any invalid member rejects + the whole batch, so retrying cannot duplicate a prefix that committed before + a later input failed. +3. For each input: - **Normalize kind** via `normalizeInputKind` (reads `kind`, `rawType`, `title`; defaults to `console_logs` when text is present, else `browser_verification`). - **Resolve storage URI** via `resolveStoredUri`: - `http(s)://` URI -> stored as-is, `storage_kind: "url"`. + - Relative paths -> try the explicit caller/lane-worktree root, then the project root. - Path within `layout.artifactsDir` -> already in the artifacts dir, stored as a project artifact URI. - Path outside artifacts dir but within `allowedImportRoots` -> copy via `secureCopyFromDescriptor` to a fresh artifact path (`createComputerUseArtifactPath`), stored as file URI. + - Missing paths, denied roots, and non-evidence extensions -> throw without creating a record. - Path outside all allowed roots -> throw "Artifact path is outside allowed import roots". - No path/uri, only `text` or `json` -> materialize inline content via `materializeInlineContent` (writes atomically via `writeTextAtomic`), stored as file URI. -3. **Insert the canonical record** via `insertArtifactRecord`. -4. **Insert links** for each unique owner via `insertLink`. -5. **Emit event** via `onEvent` callback so renderer surfaces refresh. +4. **Insert the canonical record** via `insertArtifactRecord`. +5. **Insert links** for each unique owner via `insertLink`. +6. **Emit event** via `onEvent` callback so renderer surfaces refresh. ### Allowed import roots @@ -84,12 +89,20 @@ Fixed set, constructed in the broker factory: ``` layout.artifactsDir // .ade/artifacts +layout.cacheDir // .ade/cache layout.tmpDir // .ade/tmp +layout.worktreesDir // managed lane worktrees +projectRoot // captures written beside project source os.tmpdir() // OS temp dir ~/.agent-browser // agent-browser output dir ``` -This list is the trust boundary for external ingestion. Adding a new root requires a code change. `isAllowedExternalArtifactSource(absolutePath, roots)` enforces this using `resolvePathWithinRoot` per root. +Runtime callers may add an explicit trusted import root (for example the +machine-local browser observation root). `.ade/secrets` is always denied. +Allow- and deny-root checks compare real paths, and the file itself must have an +allow-listed image/video/trace/log extension; this prevents a project-local +`.env`, database, key, or certificate from being promoted into proof merely +because the project root is allowed. ### Secure copy @@ -120,7 +133,7 @@ This is the symlink-safe copy path. Do not replace with plain `copyFileSync` — { kind: "lane" | "chat_session" | "automation_run" | "github_pr" | "linear_issue", id: string, - relation?: "attached_to" | "produced_by" | "referenced_by" | "published_to" | ..., + relation?: "attached_to" | "produced_by" | "published_to", metadata?: Record } ``` @@ -131,77 +144,97 @@ Owner precedence for snapshots (`usageEventMatchesOwner`): ## Review state -`ComputerUseArtifactReviewState` values: `pending`, `accepted`, `needs_more`, `dismissed`, `published`. Newly ingested proof defaults to `accepted`; the field remains for compatibility with downstream workflow consumers rather than a chat approval step. +`ComputerUseArtifactReviewState` values: `pending`, `accepted`, `needs_more`, `dismissed`. Newly ingested proof defaults to `accepted`; the field remains for compatibility with operator tooling rather than a chat approval step. -`reviewArtifact(args)` updates state and records the decision. Review decisions are persisted alongside the artifact for audit. +`updateArtifactReview(args)` updates state and records the decision. Review decisions are persisted alongside the artifact for compatibility. These fields remain in the broker contract for compatibility with downstream automation and publishing integrations. The chat transcript, proof drawer, and iOS artifact surfaces do not render review/workflow controls; to users, proof is only the collected artifact set. -`ComputerUseArtifactWorkflowState` values: `evidence_only`, `awaiting_publication`, `published`, `retained`, `purged`. Default is `evidence_only`. Used to track publication lifecycle separately from review. +`ComputerUseArtifactWorkflowState` values: `evidence_only`, `promoted`, `published`, `dismissed`. Default is `evidence_only`. ## Routing and promotion **Removed.** `routeArtifact` shipped with full IPC + preload + action-domain plumbing and never had a production caller. Re-linking an artifact to a second owner is done by ingesting with the owners you want. -Example promotion flow: +An ingest request can still supply multiple owners up front. There is no later +route/promotion mutation in the shipping product. -``` -chat_session:abc (relation: attached_to) - -> github_pr:123 (relation: published_to) - -> linear_issue:LIN-456 (relation: published_to) -``` +## Availability, recovery, and deletion + +`listArtifacts()` projects each record to a view with: + +- `available` — bytes are readable (or the record is URL-backed). +- `missing_file` — the canonical artifact-store URI exists but the file is gone. +- `unimported` — a historic record points outside the artifact store. + +Older hosts can omit availability; clients attempt the preview. +`listBrokenArtifacts()` returns both broken classes and a `recoverablePath` when +the original source survives. `recoverArtifact()` runs that source through the +same realpath, extension, and secure-copy gates as normal ingestion. +`pruneBrokenArtifacts()` deletes broken records that cannot usefully render. -All links remain in `computer_use_artifact_links`; the artifact record itself is unchanged. +`deleteArtifacts()` is idempotent and removes database rows plus only those +files that resolve inside the artifact-store jail. It reports deleted, missing, +and failed ids plus bytes freed. `deleteArtifactsForLane()` removes captures +owned by a deleted lane but preserves an artifact linked to a chat in another +lane. Lane archive is non-destructive. `deleteAllArtifacts()` supports project +local-data reset, while `purgeArtifactRecordsUnder()` keeps Settings storage +cleanup from deleting bytes without their records. ## Event emission -`onEvent(payload: ComputerUseEventPayload)` fires after every successful ingestion, review, or routing change. Renderer surfaces subscribe to this stream to refresh the proof drawer or Settings readiness snapshot without polling. +`onEvent(payload: ComputerUseEventPayload)` fires after successful ingestion, +linking, review, and deletion. Renderer surfaces subscribe to this stream to +refresh the proof drawer without polling. A deletion with no surviving owner +still carries the last known owner when one existed, so owner-scoped subscribers +can invalidate. ## Snapshots `buildComputerUseOwnerSnapshot(args)` in `controlPlane.ts`: - Calls `broker.listArtifacts({ owner, limit })`. -- Computes `presentKinds` (kinds actually ingested for this scope). -- Computes `missingKinds` (required kinds not yet present). -- Finds the active backend via latest artifact -> policy pref -> first available. -- Emits a list of `ComputerUseActivityItem` entries for the UI via `buildActivity`: - - Usage events (`backend_tool_used`). - - Backend state events (`backend_connected`, `backend_unavailable`, `backend_available`). - - Artifact ingestion events (`artifact_ingested`). - - Missing proof events (`proof_missing`). -- Sorted newest first, limited to 8. +- Keeps the newest five as `recentArtifacts`. +- Selects the active backend from the latest artifact, then the first currently + available backend. +- Builds the owner summary from retained proof or current availability. +- Emits up to eight artifact-derived activity rows using each artifact's real + timestamp. It does not fabricate "just now" readiness/history entries; + missing/unimported artifacts become warning activity rows. ## Publishing Artifacts flow into downstream workflow surfaces: - **Lane history** — linked lane surfaces the artifact in the lane timeline. -- **Chat history** — linked chat sessions render the newest proof at the - transcript tail and keep the complete set in the proof drawer. -- **GitHub PR workflows** — linked PR gets a comment with the artifact reference (when published). -- **Linear issue** — a linked Linear issue can get a comment + optional state transition through the shared Linear write surface. -- **Automations history** — linked automation run shows the artifact in the run log. +- **Chat history** — linked chat sessions bucket proof by capture time and + expose a collapsed filmstrip from the producing turn; the drawer keeps the + complete set. +- **Lane cleanup** — lane-linked or lane-attributed records are removed with a + destructive lane delete, unless another lane's chat still owns the artifact. There is no publication path in the shipping product; `updateArtifactReview` exists only for the CTO operator tool. ## Invariants - **One canonical artifact per captured moment.** Re-ingesting an identical source path should not create a duplicate record — the caller is expected to dedupe via content hashing before calling the broker. The broker does not hash-dedupe automatically. -- **Links are additive.** Owners are appended, not replaced. Revoking an ownership is a soft-delete via metadata, not a row removal. +- **Links are additive during ingest.** The removed routing API cannot append + owners later; destructive artifact deletion removes the record and its links. - **`secureCopyFromDescriptor` is the only path-based ingestion path.** Adding a new path-based ingestor requires using this helper. - **Storage URIs point into the project or are `http(s)` URLs.** Never persist raw external absolute paths as a storage URI — the broker resolves them to project-relative paths at ingestion time. +- **Bytes and rows are one lifecycle.** Lane deletion, project-local-data reset, + and Settings proof cleanup must remove both. ## Gotchas -- **Empty inputs are silently skipped.** `pushInput` in `agentBrowserArtifactAdapter` rejects inputs with no path/uri/text/json — this means a malformed payload produces zero artifacts with no error. Validate upstream. +- **Missing/invalid path inputs fail loudly.** They do not create dead records, + and a multi-input request does not partially commit. - **`materializeInlineContent` respects JSON vs text.** Passing both `text` and `json` writes the JSON (text is ignored). Don't rely on the ordering for mixed payloads; pick one. - **`toProjectArtifactUri` produces project-relative URIs.** When rendering artifacts in a UI component, resolve these against the current project root — hard-coding a prefix will break with different projects. - **`inferArtifactExtension` reads only the file path/URI extension.** MIME-type-based inference is not attempted; set `mimeType` explicitly if the extension is wrong. -- **Reviewer decisions can change the workflow state.** A `published` review state usually implies `workflowState: "published"`, but the broker does not enforce the correlation — check both fields when deciding whether to re-publish. - **Event emission is best-effort.** `onEvent` callbacks that throw are swallowed. Do not rely on the event bus for ACID transitions — read back from the broker instead. ## Cross-links diff --git a/docs/features/computer-use/backends.md b/docs/features/computer-use/backends.md index b609d0b3a..f4b6a4753 100644 --- a/docs/features/computer-use/backends.md +++ b/docs/features/computer-use/backends.md @@ -8,7 +8,6 @@ The later sections describe the historical Ghost OS / agent-browser / local-fall - `apps/desktop/src/main/services/computerUse/controlPlane.ts` — pre-rebuild `buildComputerUseOwnerSnapshot` + capability/Ghost-OS helpers. The current build keeps the snapshot assembly path; the policy/Ghost-OS readiness helpers are vestigial. - `apps/desktop/src/main/services/computerUse/localComputerUse.ts` — `getLocalComputerUseCapabilities`, `createComputerUseArtifactPath`, `toProjectArtifactUri`. Capability detection (`screencapture`, `open`, `swift`, `osascript`) reflects the runtime host's environment. -- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` — `parseAgentBrowserArtifactPayload`, `loadAgentBrowserArtifactPayloadFromFile`. Parses agent-browser output manifests on the runtime host. - `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` — `getBackendStatus` (emits `ComputerUseBackendStatus`), `secureCopyFromDescriptor` (symlink-safe path-based ingest), backend enumeration. - `apps/desktop/src/main/utils/codexComputerUse.ts` — current direct Codex client resolver and signature/opt-in boundary. - `apps/desktop/src/main/services/chat/agentChatService.ts` — injects the MCP config into app-server threads and maps Computer Use MCP calls/elicitations into chat events. @@ -90,38 +89,26 @@ The remaining sections document the retired readiness/policy model. ## agent-browser -**Transport:** CLI-native. Not an ADE CLI. Runs externally, produces a manifest or output files, ADE ingests after the fact. +**Transport:** external CLI. It runs outside ADE and produces output files; +ADE ingests only the explicit paths the caller supplies through +`ade proof attach` or `ingest_computer_use_artifacts.inputs`. -**Installation flow:** - -1. Install the `agent-browser` CLI locally. -2. Confirm in Settings > Computer Use that the CLI is detected (`commandExists("agent-browser")`). -3. Run agent-browser externally. -4. Ingest its manifests or output files into ADE via the broker. - -**Payload parser** (`agentBrowserArtifactAdapter.ts`): - -`parseAgentBrowserArtifactPayload(payload)` accepts either an array of entries or an object with recognized fields: - -- `artifacts: []` — explicit array of entries (`{ kind, title, description, path, uri, text, json, mimeType, rawType, metadata }`). -- Direct mappings: - - `screenshotPath` / `imagePath` -> `screenshot`. - - `videoPath` -> `video_recording`. - - `tracePath` -> `browser_trace`. - - `consoleLogsPath` / `consoleLogPath` -> `console_logs`. - - `verificationPath` -> `browser_verification`. -- Direct text mappings: - - `consoleLogs` / `consoleLog` -> `console_logs` (inline text). - - `verificationText` -> `browser_verification` (inline text). - -`loadAgentBrowserArtifactPayloadFromFile(filePath)` is the convenience wrapper — reads JSON and parses. +The retired manifest adapter and its backend-specific field aliases were +deleted. ADE no longer interprets an arbitrary agent-browser JSON payload or +accepts `manifestPath`; the caller names each proof input using the canonical +`ComputerUseArtifactInput` shape. **Kind inference fallback:** - `normalizeInputKind` reads the explicit `kind`, then `rawType`, then `title`. - If nothing matches, `input.text` present implies `console_logs`; otherwise defaults to `browser_verification`. -**Allowed-source enforcement:** When ingesting agent-browser artifacts by path, the path must resolve within one of the allowed roots (`.ade/artifacts`, `.ade/tmp`, `os.tmpdir()`, `~/.agent-browser`). Paths outside these roots are rejected. +**Allowed-source enforcement:** When ingesting agent-browser artifacts by path, +the path must resolve within a broker-approved root (including +`~/.agent-browser`, project/lane/cache/temp roots, or a narrowly injected +browser-observation root), must not resolve under `.ade/secrets`, and must have +an allow-listed evidence extension. The broker securely copies it into the +artifact store before persisting the row. ## ADE local (fallback-only) diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 4926185b5..4585db35e 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -471,7 +471,11 @@ a lane parented to primary would always show zero behind. `database_cleanup` step wraps every cascade delete inside a single `begin immediate` / `commit` transaction so a partial failure rolls back to a consistent DB state instead of leaving lane rows - half-deleted. Generic ADE action calls + half-deleted. Before that transaction, ADE collects and deletes proof files + attributed by `computer_use_artifacts.lane_id` or a legacy lane owner link. + Every file is realpath-confined to `.ade/artifacts`; a capture also owned by + a chat in another lane survives, and archive remains non-destructive. + Generic ADE action calls (`lane.delete` through `ade actions run` / TUI `/ade`) use the same teardown path, including lane-environment cleanup and port lease release. The ADE Code TUI also surfaces this through a dedicated diff --git a/docs/features/lanes/worktree-isolation.md b/docs/features/lanes/worktree-isolation.md index ea08df418..e2923e9f5 100644 --- a/docs/features/lanes/worktree-isolation.md +++ b/docs/features/lanes/worktree-isolation.md @@ -107,11 +107,15 @@ the directory first: 6. If caller requested `deleteBranch`: `git branch -D `. Optional remote branch cleanup uses `git push --delete ` and is non-fatal. -7. Remove lane pack artifacts and delete the lane's database rows in - one transaction. Stale state in `key_value`, `operations`, - `sessions`, etc. that references the lane is either cascaded - (via FK ON DELETE) or retained for audit as documented on each - table. +7. Collect lane-owned proof paths while chat/lane ownership rows still exist, + resolving each through the realpath-confined `.ade/artifacts` jail. Remove + the stored files, remove lane pack artifacts, and delete the lane's database + rows in one transaction. An artifact belongs to the lane through + `computer_use_artifacts.lane_id` or a legacy explicit lane link; an artifact + also owned by a chat in another lane is preserved. Archive does none of this. + Stale state in `key_value`, `operations`, `sessions`, etc. that references + the lane is either cascaded (via FK ON DELETE) or retained for audit as + documented on each table. Independent lane deletes can run through the pre-removal teardown at the same time. The shared guard is scoped to the actual diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 07ec8a452..3e1efd005 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -392,7 +392,8 @@ Renderer — settings: per-row prune/compact actions; the "Health & diagnostics" strip (`StorageDiagnostics`, anchored `#diagnostics`); and the collapsible recent-cleanups journal (`StorageMaintenanceJournal`). `storageView.ts` holds the pure - snapshot→cleanup-target mapping plus the diagnostics/maintenance view-model + snapshot→cleanup-target mapping (including the explicit `review_first` + `proof_attachments` target) plus the diagnostics/maintenance view-model (unit-tested without a DOM); the dashboard reads `window.ade.storage.*` and `window.ade.app.getRuntimeHealth`. The domain lives in [Storage and recovery](../storage-and-recovery/README.md), which owns the @@ -764,8 +765,8 @@ changing rather than which service backs it: | Appearance | `AppearanceSection.tsx` (renders `ChatAppearancePreview`) | Theme, code-block copy-button position, chat font size, transcript density, chrome tint, shell geometry, the user-message minimap toggle, and the default-on prompt-stash bookmark visibility. Hiding the bookmark does not disable Cmd/Ctrl+S. Persisted to `localStorage` under `ade.userPreferences.v1`. | | AI Connections | `ProvidersSection.tsx`, `OAuthConnectModal.tsx` | Two groups: **Coding Agents** cards (Claude Code, Codex CLI, Cursor, Droid) and **OpenCode — Universal Model Access** (models.dev catalog freshness + Subscriptions/OAuth & Kimi, API Provider Keys incl. Moonshot AI, a searchable ~160-provider chip cloud, Local Model Servers, and Advanced custom providers/model slugs). Subscription connects run through `OAuthConnectModal`; custom providers/slugs persist to `ai.customProviders` / `ai.customModelSlugs`. When Claude is installed but unauthenticated, the shared `Login to Claude` CTA opens a primary-lane terminal running `claude auth login` and navigates to Work. Legacy `?tab=providers` lands here. | | Background Jobs | `AiFeaturesSection.tsx` | AI-powered automations: summaries, PR descriptions, commit messages, auto-naming, plus project-wide scheduled-work recovery. **Pause all scheduled work** keeps Claude wakeups, cron tasks, and loops armed while suppressing `nextWakeAt`; on resume each overdue schedule runs once before cron work returns to its normal cadence. **Active scheduled work** lists KV-backed durable jobs from every chat with per-job Cancel and an explicit unavailable/error state. Legacy `?tab=automations` lands here. Each feature row has an independent reasoning-effort override (`ReasoningEffortPicker` with `useFamilyDefaults={false}`). | -| Lane Templates | `LaneTemplatesSection.tsx`, `LaneBehaviorSection.tsx` | Lane init recipes and creation/rebase behavior | -| Storage | `StorageSection.tsx`, `storage/StorageCleanupDialog.tsx`, `storage/StorageDiagnostics.tsx`, `storage/StorageMaintenanceJournal.tsx`, `storage/storageView.ts` | Disk-usage and lane-storage dashboard. Explains all cleanup rules in plain language and reviews archived lanes, orphaned worktrees, DerivedData, and build output with ownership, age, blocked reasons, and reclaim estimates. Archive & Reclaim is typed-confirmation only and explains what remains and how restore recreates the worktree. Also includes categories and policy chips, database breakdown, storage doctor, Health & diagnostics, recent cleanups, preview-confirmed generic cleanup, and manual history compression. Reads `window.ade.storage.*`, `window.ade.projectConfig.*`, `window.ade.lanes.*`, and `window.ade.app.getRuntimeHealth`. Deep links from `?tab=storage` and `?tab=disk` (via `TAB_ALIASES`); the top-bar load pill deep-links to `?tab=storage#diagnostics` (`?tab=diagnostics` also aliases here). See [Storage and recovery](../storage-and-recovery/README.md). | +| Lane Templates | `LaneTemplatesSection.tsx`, `LaneBehaviorSection.tsx` | Lane init recipes, creation/rebase behavior, and lane lifecycle policy. | +| Storage | `StorageSection.tsx`, `storage/StorageCleanupDialog.tsx`, `storage/StorageDiagnostics.tsx`, `storage/StorageMaintenanceJournal.tsx`, `storage/storageView.ts` | Disk-usage and lane-storage dashboard. Explains all cleanup rules in plain language and reviews archived lanes, orphaned worktrees, DerivedData, build output, and proof/attachments with ownership, age, blocked reasons, and reclaim estimates. Archive & Reclaim is typed-confirmation only and explains what remains and how restore recreates the worktree. Proof/attachments are selectable `review_first` cleanup targets; after deleting their bytes, the backend removes matching proof records. The page also includes categories and policy chips, database breakdown, storage doctor, Health & diagnostics, recent cleanups, preview-confirmed generic cleanup, and manual history compression. Reads `window.ade.storage.*`, `window.ade.projectConfig.*`, `window.ade.lanes.*`, and `window.ade.app.getRuntimeHealth`. Deep links from `?tab=storage` and `?tab=disk` (via `TAB_ALIASES`); the top-bar load pill deep-links to `?tab=storage#diagnostics` (`?tab=diagnostics` also aliases here). See [Storage and recovery](../storage-and-recovery/README.md). | | Stats | `AdeUsageSection.tsx`, `ActivityModule.tsx`, `providerColors.ts` | Usage page with live Limits plus a sectioned Activity dashboard: overview stat tiles, an activity/tokens/code/clients module, and split AI-usage and GitHub-vs-local Code & PRs panels, with project/machine scope and day/week/month/year/all ranges. Fast cached local-provider, project-DB, GitHub, and cross-client activity. Deep links from `?tab=usage` and `?tab=stats` land here. | > Live provider quota windows and automation guardrails live in the top-bar Usage popup (`HeaderUsageControl.tsx` → `UsageQuotaPanel.tsx` + collapsible `BudgetCapEditor`) and Settings > Usage > Limits. The Activity tab is the retrospective cross-client dashboard. diff --git a/docs/features/proof.md b/docs/features/proof.md index 95fe7ce55..eabfa1abd 100644 --- a/docs/features/proof.md +++ b/docs/features/proof.md @@ -8,6 +8,20 @@ The old system sat upstream of the agent and tried to normalize every backend. I The result: one interface for all models, no backend matrix, no coverage math. A proof set is a handful of captioned screenshots a reviewer can skim in under a minute. +## Source file map + +| Path | Role | +|---|---| +| `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` | Canonical ingest, list, delete, broken-record audit/prune/recovery, lane/project cleanup, review compatibility fields, and bounded preview reads. | +| `apps/desktop/src/main/services/computerUse/controlPlane.ts` | Owner-scoped snapshots used by the proof drawer. | +| `apps/desktop/src/main/services/computerUse/localComputerUse.ts` | Local capture capabilities plus artifact path/URI helpers. | +| `apps/desktop/src/main/services/state/kvDb.ts` | `computer_use_artifacts` / `computer_use_artifact_links` schema, including the optional lane ownership column used by lane cleanup. | +| `apps/desktop/src/shared/types/computerUseArtifacts.ts` | Cross-process artifact, availability, deletion, recovery, event, and owner contracts. | +| `apps/desktop/src/main/services/adeActions/registry.ts`, `apps/desktop/src/main/services/ipc/registerIpc.ts`, `apps/desktop/src/preload/preload.ts` | Runtime action, IPC, and renderer bridge for the proof surface. | +| `apps/ade-cli/src/cli.ts`, `apps/ade-cli/src/adeRpcServer.ts` | Typed `ade proof …` commands and JSON-RPC tools. | +| `apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx` | Full proof drawer, artifact tiles, preview states, and delete action. | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx`, `chatCardPrimitives.tsx` | Turn-time bucketing plus the collapsible inline proof filmstrip. | + ## Runtime ownership Proof storage and the broker are owned by the ADE runtime (`ade serve`) that owns the project. Artifacts on disk live under the runtime machine's `.ade/artifacts/computer-use/` directory; the SQLite rows live in that runtime's `.ade/ade.db`. For local projects that is the user's machine; for remote projects it is the remote machine. The desktop renderer and the headless ADE CLI both call into the broker over JSON-RPC; nothing about the proof pipeline lives in the renderer or in a separate host process. @@ -55,7 +69,7 @@ The CLI infers the proof kind from the file extension: | `.png`, `.jpg`/`.jpeg`, `.webp`, `.gif`, `.heic`/`.heif`, `.tif`/`.tiff` | `screenshot` | | `.mov`, `.mp4`, `.m4v`, `.webm` | `video_recording` | | `.zip`, `.har` | `browser_trace` | -| anything else | `browser_verification` | +| any other allow-listed extension | `browser_verification` | Example: @@ -65,6 +79,12 @@ ade proof attach /tmp/playwright-run/checkout-success.png --caption "checkout fl The file is copied into `.ade/artifacts/computer-use/`; the original is left in place. Internally `attach` calls the same `ingest_computer_use_artifacts` RPC tool with `backendStyle: "manual"` and `backendName: "ade-cli"`. +On-disk imports are intentionally allow-listed to renderable evidence types +(images, video, browser traces, and text/log files). Files such as `.env`, +databases, private keys, and certificates are rejected even when they are under +the project root. The broker resolves symlinks for both its allow- and deny-root +checks and opens the source with `O_NOFOLLOW` before copying. + ### `ade proof list` Print the proof set for the current session as JSON. @@ -81,6 +101,16 @@ No args: lists the inferred session. Primarily for agents to see what they have - `ade proof record --seconds ` records a short video proof where supported. - `ade proof launch`, `ade proof interact`, and `ade proof environment` are lower-level computer-use helpers for capture workflows. - `ade proof ingest --input-json ...` ingests externally produced artifacts directly through the proof broker. +- `ade proof rm […]` irreversibly deletes the selected + records and any stored files inside the artifact jail. Missing ids are + reported but do not make deletion non-idempotent. +- `ade proof broken` lists records whose file is missing or whose historic URI + never pointed into the artifact store. +- `ade proof prune` is the non-destructive broken-record listing; add + `--broken` to delete every broken record. +- `ade proof recover ` re-imports a broken record when its original + file still exists in an allowed project, lane-worktree, cache, temp, or + browser-output root. --- @@ -118,9 +148,27 @@ Images live on disk under the project's `.ade/` scaffold on the runtime host: (Path will move to `.ade/artifacts/proof/` in a future phase.) -Metadata is a single SQLite row per capture in `computer_use_artifacts`, with ownership links in `computer_use_artifact_links`. The columns relevant to the new system are a small subset of what the table carries today: `id`, `kind` (`screenshot` for captures; attached files are normalized to screenshot, video recording, browser trace, or browser verification by extension), `uri`, `mime_type`, `caption`, `created_at`, plus the owner link row. +Metadata is a single SQLite row per capture in `computer_use_artifacts`, with ownership links in `computer_use_artifact_links`. The columns relevant to the proof surface include `id`, `kind`, `uri`, `mime_type`, `title`/`description`, `lane_id`, and `created_at`, plus the owner link rows. `lane_id` is resolved from an explicit lane owner or from the owning chat session; it is optional on the wire for compatibility with older runtimes. + +There is no age-based retention policy. Captures persist until the user deletes +them, deletes their lane, clears project-local artifacts, or removes the proof +storage category in Settings. All of those paths delete matching database rows +with the bytes so the drawer never retains knowingly dead tiles. Archiving a +lane is non-destructive. For remote-runtime projects, the disk being filled is +the remote host's, not the desktop machine's. + +Ingest resolves every item in a batch before inserting any row. A missing file, +denied source, disallowed extension, or path outside the allowed roots fails the +whole batch instead of leaving a half-committed retry. Relative paths resolve +from the caller's lane worktree (`callerRoot`) before the project root. + +Each view includes an optional availability classification: + +- `available` — URL-backed or stored bytes are readable. +- `missing_file` — a canonical artifact URI exists but the file is gone. +- `unimported` — a historic record points outside the artifact store. -There is no retention policy — captures persist until the project is cleaned up. Disk is the budget; nothing ages out automatically. For remote-runtime projects, the disk being filled is the remote host's, not the desktop machine's. +Older hosts may omit the field; clients optimistically attempt the preview. ADE browser-agent observations are intentionally not proof. `ade browser observe` and post-action browser observations write scratch PNG/JSON files under `.ade/cache/browser-observations/` for project collections and the current ADE channel's machine-local `browser-observations/personal/` root for personal collections. They include a bounded DOM element list plus console/network diagnostics by default for agent targeting, can add a numbered visual UI map with `--map`, and prune to the latest 3 observations per tab by default. DOM elements carry short-lived handles such as `obs-...:e:3` so agents can click/fill/press/wait without another hit-test, including same-origin iframe/open-shadow-root targets when the observation captured that context. `ade browser session start --tab ` only creates a reusable tab-targeting handle for repeated agent actions; session observations and traces are still scratch state until promoted. `ade browser trace --tab ` or `ade browser trace --browser-session ` exposes the bounded per-tab action log for debugging but remains scratch state. Promote only reviewer-facing checkpoints into proof through `ade browser proof --tab --caption "..."`, `ade browser proof --browser-session --caption "..."`, the shorthand `ade browser session proof --caption "..."`, or the lower-level `ade proof attach` / `ingest` commands. The proof broker explicitly allows the project cache and personal browser scratch roots so browser scratch PNGs can be promoted without accepting arbitrary user-data files. @@ -130,13 +178,16 @@ ADE browser-agent observations are intentionally not proof. `ade browser observe Proof surfaces across chat and linked workflow contexts: -- **Chat transcript** — the latest six proof items render at the thread tail - with image thumbnails or inline video. Images open in an in-app lightbox. - Earlier items link to the drawer rather than making the thread unbounded. +- **Chat transcript** — proof is bucketed by capture time into the turn that + produced it. The turn rule shows a compact `N proof` control; expanding it + reveals a horizontally scrollable filmstrip directly below that turn. It + starts collapsed, remains in chronology when newer messages arrive, and is + never pinned to the thread tail. - **Proof drawer** — the current chat's complete collected set, with the same - previews and captions. It is a collection view, not an approval workflow: - there are no accept/reject/publish controls and local files are never handed - to Finder just to see them. + previews and captions plus irreversible artifact deletion. It is a + collection view, not an approval workflow: there are no + accept/reject/publish controls and local files are never handed to Finder + just to see them. - **iOS chat** — proof stays in the message timeline and the existing artifact sheet, with preview/share actions but no review-state chrome. - **Lane and PR review** — linked proof can be surfaced alongside lane work and PR closeout. @@ -144,7 +195,9 @@ Proof surfaces across chat and linked workflow contexts: Both clients resolve media through the owning runtime instead of opening the runtime host's filesystem path. Desktop uses ADE's range-capable artifact protocol for local media and `ade.proof.readArtifactPreview` for remote media; -the RPC response is capped at 10 MiB. iOS requests artifact content over its +the RPC response is capped at 10 MiB. The inline filmstrip can resolve local +project-relative artifacts synchronously; remote filmstrip tiles fall back to a +kind label and open the drawer, which performs the bounded runtime read. iOS requests artifact content over its sync command surface and caches renderable images locally. When a runtime is unreachable or a desktop remote preview exceeds its bound, the artifact remains listed with an unavailable-preview state. @@ -207,6 +260,5 @@ The broker (`apps/desktop/src/main/services/computerUse/computerUseArtifactBroke - `controlPlane.ts` builds owner snapshots + backend status for the UI. - `localComputerUse.ts` reports macOS-only proof-capture capabilities (`screencapture`, app launch, GUI interaction). Reflects the runtime host's environment, not the desktop machine's. -- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` parses agent-browser output into `ComputerUseArtifactInput[]`. Provider execution can be provisioned by ADE (for example the signed direct Codex Computer Use MCP client), but proof remains explicit. Every piece downstream of `ade proof` is a thin line to disk, a broker insert, and the drawer. No passive observer promotes provider tool calls automatically — the proof observer was deleted with this rebuild, along with `ComputerUsePolicy` and the Settings > Computer Use panel. diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index cfce81bdd..c30e0893b 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -83,7 +83,7 @@ Service files (`apps/desktop/src/main/services/prs/`): | `prMergeQueue.test.ts` | Merge-queue transition and landing coverage. | | `prAsync.test.ts` | Shared bounded-concurrency and async helper coverage. | | `prPollingService.ts` | 60 s fallback polling loop, fingerprint-based change detection, notification emission, targeted webhook reconciliation, and GitHub rate-limit backoff. `reconcilePrs(prIds)` coalesces webhook-linked PR ids and refreshes only those rows immediately; ordinary `poke()` still requests a normal tick. User/queue-driven hot windows poll affected PRs every 5 s for the first minute and 15 s until the three-minute cap, but poll results cannot start or restart a hot window. Writes `last_polled_at` per PR so callers can run delta polls on the next tick. The ADE daemon owns an instance (created + started + disposed in `apps/ade-cli/src/bootstrap.ts`) so background polling and PR events run for runtime-bound windows; the desktop main process still owns one for local-bound windows. When zero PRs are tracked yet, the forced full-snapshot `discoverLanePullRequests` fetch is throttled to a 10-minute cadence instead of running every tick — user-driven surfaces discover PRs on their own reads anyway | -| `prChatCards.ts` | Converts bounded PR polling transitions into durable `ade_card` episodes for linked Work chats: CI completion/failure, review received, merge ready, conflicts, and merged. Desktop-main and daemon-owned pollers call the same emitter, and failures are isolated per PR/session so one cold or malformed chat cannot stop the poll loop. | +| `prChatCards.ts` | Converts bounded PR polling transitions into durable `ade_card` episodes for linked Work chats: CI completion/failure, review received, merge ready, conflicts, and merged. CI jobs are failure-first, capped at three visible rows with `rowsTruncated`, and report an honest `degradedReason` + Retry action when both job/check detail sources fail instead of rendering an empty success state. Desktop-main and daemon-owned pollers call the same emitter, and failures are isolated per PR/session so one cold or malformed chat cannot stop the poll loop. | | `prSummaryService.ts` | AI PR summary generator; caches `PrAiSummary` per `(prId, headSha)` in `pull_request_ai_summaries` so pushes invalidate the cache | | `workflowGraph.ts` | `createWorkflowGraph` — reconstructs the CI pipeline DAG (`PrWorkflowGraph`) behind a swappable `WorkflowGraph` interface. GitHub's jobs API does not return `needs:`, so the graph is built by parsing the workflow YAML that actually ran and joining it to live run state. Parses **only** `jobs..needs` and `jobs..strategy.matrix`, with the existing `yaml` dep. Source order: lane worktree `git show :.github/workflows/` → GitHub Contents API `?ref=` (fork PRs / non-local repos) → `source: "none"` with an `unavailableReason`; it never guesses an edge. A single WORKFLOW degrades to flat swimlanes (not the whole graph) when a job uses a reusable workflow (`uses:`), has a `${{ }}` `name:`, or the YAML will not parse. Matrix legs collapse into one node whose state is the worst leg (failed > running > queued > passed > skipped); `tier` is a cycle-safe longest-path rank over `needs`; `criticalPath` is the longest-duration chain. Running nodes report live elapsed. Parsed YAML is cached per `(repo, headSha)` behind a TTL; the graph itself is always recomputed from live run state. | | `checkLogParser.ts` | Pure parsing for `prService.getCheckLog`: strips the per-line ISO timestamp, splits a job log on top-level `##[group]` / `##[endgroup]` markers into step sections, selects the failing step's section, and lifts a framework summary headline (vitest/jest/pytest/go) — falling through to `null` rather than guessing. `prService` owns the bounded streaming download (the logs endpoint 302s to a pre-signed blob; the redirect is followed without the API token and reading stops past a few MB, setting `truncated`). | @@ -769,6 +769,14 @@ in place rather than append. Review cards include the latest reviewer and unresolved-thread count. Every card carries a PR `navTarget`; CI targets include `detailTab: "checks"` on desktop, iOS, and TUI/deeplink fallback. +CI detail ranks failed → running → queued → unknown → skipped → passed and +shows at most three rows, followed by `+N more`. A rejected GitHub runs/checks +request is kept distinct from a genuine empty result. If one source still +returns jobs, the card renders that real detail. If both leave the card empty, +the payload carries `degradedReason`, no false status metric, and a Retry +action. A later degraded re-emit preserves the last rich rows/progress/metrics +as stale instead of blanking the chronological episode. + `emitAdeCard` uses the normal durable transcript commit path, including for a cold or idle provider session. It never relies on a model emitting special prose, and it never uses the live-only envelope path, so cards replay after a diff --git a/docs/features/storage-and-recovery/README.md b/docs/features/storage-and-recovery/README.md index f321e7cf8..adab07940 100644 --- a/docs/features/storage-and-recovery/README.md +++ b/docs/features/storage-and-recovery/README.md @@ -22,7 +22,7 @@ | `apps/desktop/src/main/services/runtime/projectRecoveryService.ts` | Brain-independent diagnosis and ordered repair: space, ownership, database validation, migration recovery, service restart, endpoint/project verification, and chat reconciliation. | | `apps/desktop/src/main/services/storage/diskPressure.ts` | Samples all ADE storage roots, classifies pressure with recovery hysteresis, and gates write-producing operation classes via `canPerform(kind)`. Exports the `DiskPressureMonitor` type and refusal-message copy. | | `apps/desktop/src/main/services/storage/volume.ts` | `readVolumeSpace(dir)` (statfs free/total bytes) and `isNoSpaceError(err)` (ENOSPC/EDQUOT and disk-full message detection), shared by the pressure monitor and the database-open error classifier. | -| `apps/desktop/src/main/services/storage/storageInsightsService.ts` | Builds categorized storage snapshots and preview-confirmed cleanup plans without following symlinks or deleting protected state. It also runs the lane-lifecycle scan at the configured interval: safely archives excess or inactive lanes, marks old archived worktrees for review, and never removes lane files in the background. The **storage doctor** compresses history and maintains the database; filesystem candidates such as staging, backups, DerivedData, and build output remain review-first. Every run is journaled and emits one deduped `ade_feature_used` analytics event. Populates the snapshot's optional `extras` plus lifecycle policy/status and per-item ownership, age, blocked reasons, and reclaim estimates. | +| `apps/desktop/src/main/services/storage/storageInsightsService.ts` | Builds categorized storage snapshots and preview-confirmed cleanup plans without following symlinks or deleting protected state. `proof_attachments` is a manual `review_first` cleanup target for `.ade/artifacts` and `.ade/attachments`; after bytes are removed it invokes the broker's `purgeArtifactRecordsUnder` hook so proof rows cannot outlive their files. It also runs the lane-lifecycle scan at the configured interval: safely archives excess or inactive lanes, marks old archived worktrees for review, and never removes lane files in the background. The **storage doctor** compresses history and maintains the database; filesystem candidates such as staging, backups, DerivedData, and build output remain review-first. Every run is journaled and emits one deduped `ade_feature_used` analytics event. Populates the snapshot's optional `extras` plus lifecycle policy/status and per-item ownership, age, blocked reasons, and reclaim estimates. | | `apps/desktop/src/main/services/lanes/laneService.ts` | Owns the lane-aware `getReclaimRisk`, `archiveAndReclaim`, and restore-aware `unarchive` operations. It proves exact path-and-branch ownership against this project's Git worktree registry, rejects symlinks, rechecks directory identity before removal, shares the database-backed lane worktree lease with PR workflows, and stores retryable reclaim failures locally. | | `apps/desktop/src/main/services/storage/storageLedger.ts` | The **storage ledger** (`STORAGE_LEDGER`): the declared policy for every persistent table and directory ADE writes — its privacy class (`user_data` / `derived` / `operational`) and how it is bounded (`write_time` / `doctor` / `both` / `manual`). `LEDGER_LAYOUT_COVERAGE` maps every `ADE_LAYOUT_DEFINITIONS` directory to a ledger id (or `null` for intentionally-unmanaged config/credentials) so a coverage test fails CI if a new tracked directory ships without a declared policy. `deriveCategoryPolicyChips()` renders the Settings policy chips from the ledger. | | `apps/desktop/src/main/services/storage/storageDbBreakdown.ts` | Pure helpers turning raw `dbstat` rows into the coarse project-database breakdown (`classifyDbTable` / `mapDbBreakdown`: webhooks, sync bookkeeping, review artifacts, PR cache, core) and `deriveSyncBookkeepingAction` — which reads the journal so the sync-bookkeeping row offers "Compact now" only after a run proves compaction ran without a `has_peers` skip, and stays "waiting to compact" otherwise. | @@ -220,6 +220,13 @@ linked pull-request group. Blocked lanes remain active. The retention rule never deletes files: it marks the archived lane `ready_for_review` so a person can inspect the estimate and explicitly confirm Archive & Reclaim. +Proof/attachment cleanup accepts only the `.ade/artifacts` or +`.ade/attachments` roots (or descendants) after the same symlink/path +validation. Removing artifact bytes calls +`computerUseArtifactBrokerService.purgeArtifactRecordsUnder()` immediately +afterward, deleting matching canonical proof rows and links. This is an +explicit `review_first` action; it is not part of automatic safe cleanup. + ### History compression A compression candidate is an inactive regular `.jsonl`/`.log` file older than diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 9c4a61fc1..fe1c85322 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -303,6 +303,9 @@ apps/ios/ │ │ │ # file-change transcript cards, inline │ │ │ # subagent spawn/result/background-chip │ │ │ # timeline rows, plus the unified Chat +│ │ │ # rich `ade_card` rows (duration, +│ │ │ # degraded/stale state, truncated counts, +│ │ │ # and failure-only mobile CI detail), │ │ │ # Info sheet — ordered Subagents / │ │ │ # Background / Schedule sections mirroring │ │ │ # desktop — the subagent strip/badge, and @@ -335,10 +338,11 @@ apps/ios/ │ │ │ # selections, over App Group UserDefaults; │ │ │ # debounced autosave + workPersistedDraft │ │ │ # view modifier), -│ │ │ # WorkArtifactTerminalViews (in-thread -│ │ │ # artifact card with a friendly -│ │ │ # "Preview isn't available" fallback when -│ │ │ # the blob can't render on-device), +│ │ │ # WorkArtifactTerminalViews (minimal +│ │ │ # collapsed in-thread proof row with a +│ │ │ # 44pt target, compact thumbnail/status, +│ │ │ # expandable preview, and a friendly +│ │ │ # unavailable fallback), │ │ │ # TerminalSessionScreen + SwiftTermSessionView │ │ │ # (full-screen SwiftTerm terminal, │ │ │ # offset resume/history paging + From 83a153932f8a3d10b5bcb03e214234701eaf3f3c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:53:56 -0400 Subject: [PATCH 05/20] Fix iOS card recovery and optional proof schema migration --- apps/ios/ADE/Services/Database.swift | 8 +++++--- apps/ios/ADE/Views/Work/WorkModels.swift | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index f93d15549..0c43278d2 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -2960,9 +2960,11 @@ final class DatabaseService { columnName: "lane_id", definition: "text" ) - try exec( - "create index if not exists idx_computer_use_artifacts_lane on computer_use_artifacts(project_id, lane_id)" - ) + if hasTable(named: "computer_use_artifacts") { + try exec( + "create index if not exists idx_computer_use_artifacts_lane on computer_use_artifacts(project_id, lane_id)" + ) + } try exec(""" create table if not exists lane_list_snapshots ( lane_id text primary key, diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index a0f01cde6..39c442371 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -569,7 +569,7 @@ struct WorkAdeCardModel: Identifiable, Equatable { let preservesEarlierDetail = existingHasDetail && (!incomingHasDetail || incoming.degradedReason != nil) - WorkAdeCardModel( + return WorkAdeCardModel( id: id, variant: incoming.variant.isEmpty ? variant : incoming.variant, isTerminal: incoming.isTerminal, From 6abf185133cd2a892faaa34fd126782c7225f64e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:12:42 -0400 Subject: [PATCH 06/20] Ignore synthetic proof secret fixture in gitleaks --- .gitleaksignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index 717cd7f17..445dd85a2 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -22,3 +22,8 @@ # gitleaks' generic-api-key rule fired only because the const identifier ends in # `KEY` and the namespaced value has mild entropy. Scoped to its original commit. 86e23824a491bc8de59697b62169783f917e63b1:apps/desktop/src/renderer/lib/bannerDismiss.ts:generic-api-key:24 + +# Synthetic filename used to prove files under `.ade/secrets` cannot be +# imported as proof artifacts. No credential value is present; keep the +# exception scoped to the original PR commit and exact test finding. +882f4698208467280fc85e7de7f03f8953ed785b:apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts:generic-api-key:423 From 67634c947a70869b0a4f3056bc599f7a521c6083 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:34:36 -0400 Subject: [PATCH 07/20] Fix proof retention and degraded card recovery --- .../computerUseArtifactBrokerService.test.ts | 111 ++++++++++++++++++ .../computerUseArtifactBrokerService.ts | 83 ++++++++----- .../chat/chatTranscriptRows.test.ts | 14 ++- .../components/chat/chatTranscriptRows.ts | 10 +- apps/ios/ADE/Views/Work/WorkModels.swift | 7 +- apps/ios/ADETests/ADETests.swift | 41 +++++++ 6 files changed, 230 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index 3cd4a5ab1..9083d16f2 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -329,6 +329,40 @@ describe("computerUseArtifactBrokerService", () => { expect(repeat.failed).toEqual([]); }); + it("keeps shared stored bytes until the final artifact record is deleted", () => { + const canonicalProjectRoot = fs.realpathSync(projectRoot); + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot: canonicalProjectRoot, + logger: createLogger(), + }); + const first = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Shared notes", text: "hello" }], + }).artifacts[0]!; + const filePath = path.join(canonicalProjectRoot, first.uri); + const second = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Shared notes again", path: filePath }], + }).artifacts[0]!; + + expect(second.uri).toBe(first.uri); + expect(broker.deleteArtifacts({ artifactId: first.id }).deleted[0]).toMatchObject({ + artifactId: first.id, + fileRemoved: false, + freedBytes: 0, + }); + expect(fs.existsSync(filePath)).toBe(true); + expect(broker.listArtifacts({ artifactId: second.id })[0]?.availability).toBe("available"); + + expect(broker.deleteArtifacts({ artifactId: second.id }).deleted[0]).toMatchObject({ + artifactId: second.id, + fileRemoved: true, + }); + expect(fs.existsSync(filePath)).toBe(false); + }); + it("removes rows for records whose file was already deleted", () => { const broker = createComputerUseArtifactBrokerService({ db, @@ -411,6 +445,83 @@ describe("computerUseArtifactBrokerService", () => { expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); }); + it("filters for broken artifacts before applying the result limit", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const artifactDir = path.join(projectRoot, ".ade", "artifacts", "computer-use"); + fs.mkdirSync(artifactDir, { recursive: true }); + fs.writeFileSync(path.join(artifactDir, "healthy.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', null, ?)`, + [ + "new-healthy", + "project-1", + "New healthy proof", + ".ade/artifacts/computer-use/healthy.png", + "2026-03-13T14:00:00.000Z", + ], + ); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', null, ?)`, + ["old-broken", "project-1", "Old broken proof", "nowhere/old-broken.png", "2026-03-12T14:00:00.000Z"], + ); + + expect(broker.listBrokenArtifacts({ limit: 1 }).map((entry) => entry.artifactId)).toEqual(["old-broken"]); + }); + + it("prunes broken artifacts beyond the old 2,000-record scan cap", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const artifactDir = path.join(projectRoot, ".ade", "artifacts", "computer-use"); + fs.mkdirSync(artifactDir, { recursive: true }); + fs.writeFileSync(path.join(artifactDir, "healthy.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const healthyUri = ".ade/artifacts/computer-use/healthy.png"; + db.run( + `with recursive healthy(index_value) as ( + select 0 + union all + select index_value + 1 from healthy where index_value < 1999 + ) + insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) + select + 'healthy-' || index_value, ?, 'screenshot', 'manual', 'ade-cli', null, null, + 'Healthy ' || index_value, null, ?, 'file', null, '{}', null, ? + from healthy`, + ["project-1", healthyUri, "2026-03-13T14:00:00.000Z"], + ); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', null, ?)`, + ["old-broken", "project-1", "Old broken proof", "nowhere/old-broken.png", "2026-03-12T14:00:00.000Z"], + ); + + expect(broker.pruneBrokenArtifacts().deleted.map((entry) => entry.artifactId)).toEqual(["old-broken"]); + expect(broker.listArtifacts({ artifactId: "healthy-0" })[0]?.availability).toBe("available"); + }); + it("refuses to promote files from the project secrets directory", () => { const broker = createComputerUseArtifactBrokerService({ db, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index dfbe950f8..b11843265 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -802,7 +802,21 @@ export function createComputerUseArtifactBrokerService(args: { const filePath = resolveArtifactFilePath(record); let fileRemoved = false; let freedBytes = 0; - if (filePath) { + const sharedReference = record.storageKind === "file" + ? db.get<{ id: string }>( + ` + select id + from computer_use_artifacts + where project_id = ? + and storage_kind = 'file' + and uri = ? + and id <> ? + limit 1 + `, + [projectId, record.uri, artifactId], + ) + : null; + if (filePath && !sharedReference) { try { const stat = fs.statSync(filePath); if (stat.isFile()) { @@ -851,35 +865,46 @@ export function createComputerUseArtifactBrokerService(args: { * Every record whose bytes cannot be served, with enough detail for the UI * to say what happened and offer recovery when the original file survives. */ - const listBrokenArtifacts = (args: { limit?: number } = {}): ComputerUseArtifactBrokenRecord[] => { - const limit = Math.max(1, Math.min(2000, Math.floor(args.limit ?? 1000))); - const records = readArtifactRows( - ` - select ${ARTIFACT_SELECT_COLUMNS} - from computer_use_artifacts - where project_id = ? - and storage_kind = 'file' - order by created_at desc - limit ? - `, - [projectId, limit], - ); + const collectBrokenArtifacts = (limit: number | null): ComputerUseArtifactBrokenRecord[] => { + const pageSize = 500; const broken: ComputerUseArtifactBrokenRecord[] = []; - for (const record of records) { - const availability = resolveAvailability(record); - if (availability === "available") continue; - broken.push({ - artifactId: record.id, - title: record.title, - kind: record.kind, - uri: record.uri, - createdAt: record.createdAt, - reason: availability === "unimported" ? "outside_artifact_store" : "missing_file", - laneId: record.laneId ?? null, - recoverablePath: findRecoverableSourcePath(record), - }); + let offset = 0; + + for (;;) { + const records = readArtifactRows( + ` + select ${ARTIFACT_SELECT_COLUMNS} + from computer_use_artifacts + where project_id = ? + and storage_kind = 'file' + order by created_at desc, id desc + limit ? offset ? + `, + [projectId, pageSize, offset], + ); + for (const record of records) { + const availability = resolveAvailability(record); + if (availability === "available") continue; + broken.push({ + artifactId: record.id, + title: record.title, + kind: record.kind, + uri: record.uri, + createdAt: record.createdAt, + reason: availability === "unimported" ? "outside_artifact_store" : "missing_file", + laneId: record.laneId ?? null, + recoverablePath: findRecoverableSourcePath(record), + }); + if (limit != null && broken.length >= limit) return broken; + } + if (records.length < pageSize) return broken; + offset += records.length; } - return broken; + }; + + const listBrokenArtifacts = (args: { limit?: number } = {}): ComputerUseArtifactBrokenRecord[] => { + const limit = Math.max(1, Math.min(2000, Math.floor(args.limit ?? 1000))); + return collectBrokenArtifacts(limit); }; return { @@ -1003,7 +1028,7 @@ export function createComputerUseArtifactBrokerService(args: { /** Drop every unrenderable record. Files were never there to remove. */ pruneBrokenArtifacts(): ComputerUseArtifactDeleteResult { - const broken = listBrokenArtifacts({ limit: 2000 }); + const broken = collectBrokenArtifacts(null); if (!broken.length) return { deleted: [], missing: [], failed: [], freedBytes: 0 }; return deleteArtifacts({ artifactIds: broken.map((entry) => entry.artifactId) }); }, diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 5d8abefc9..599c7b8f2 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -2976,6 +2976,7 @@ describe("ade_card transcript rows", () => { progress: { passed: 0, failed: 0, running: 0, queued: 0 }, metrics: [], degradedReason: "HTTP 403: rate limited", + actions: [{ id: "retry", label: "Retry", kind: "primary" }], })), ]); @@ -2987,19 +2988,26 @@ describe("ade_card transcript rows", () => { expect(merged.metrics).toEqual([{ label: "failed", value: "2" }]); expect(merged.stale).toBe(true); expect(merged.degradedReason).toBe("HTTP 403: rate limited"); + expect(merged.actions).toEqual([{ id: "retry", label: "Retry", kind: "primary" }]); }); - it("clears the stale marker as soon as a healthy emit brings detail back", () => { + it("clears stale degradation state and retry actions as soon as healthy detail returns", () => { const rows = collapseChatTranscriptEvents([ env("2026-07-27T10:00:00.000Z", card({ rows: [{ icon: "fail", text: "lint" }] })), - env("2026-07-27T10:00:01.000Z", card({ rows: [], degradedReason: "HTTP 403" })), - env("2026-07-27T10:00:02.000Z", card({ rows: [{ icon: "pass", text: "lint" }], degradedReason: null })), + env("2026-07-27T10:00:01.000Z", card({ + rows: [], + degradedReason: "HTTP 403", + actions: [{ id: "retry", label: "Retry", kind: "primary" }], + })), + env("2026-07-27T10:00:02.000Z", card({ rows: [{ icon: "pass", text: "lint" }] })), ]); const merged = rows[0]!.event; if (merged.type !== "ade_card") throw new Error("Expected ade_card"); expect(merged.stale).toBe(false); expect(merged.rows).toEqual([{ icon: "pass", text: "lint" }]); + expect(merged.degradedReason).toBeUndefined(); + expect(merged.actions).toEqual([]); }); it("keeps distinct cardIds as distinct rows", () => { diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index 9093a15ff..e98908a0f 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -1083,8 +1083,14 @@ function mergeAdeCardEvent( return merged; } - // Healthy emit: drop the stale marker the previous degraded emit left behind. - if (incomingHasDetail) merged.stale = incoming.stale ?? false; + // Healthy emit: drop every degradation-only field the previous failed fetch + // left behind. Full healthy card payloads omit the retry action and reason, + // so the initial spread cannot distinguish recovery from a partial patch. + if (incomingHasDetail) { + merged.stale = incoming.stale ?? false; + merged.degradedReason = incoming.degradedReason ?? undefined; + merged.actions = incoming.actions ?? []; + } return merged; } diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 39c442371..13a5b6397 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -560,12 +560,15 @@ struct WorkAdeCardModel: Identifiable, Equatable { /// optionals only overwrite when the newer payload actually carries them, so /// a terse progress ping cannot erase rows an earlier emit established. func merging(_ incoming: WorkAdeCardModel) -> WorkAdeCardModel { + let incomingProgressTotal = incoming.progress.map { + $0.passed + $0.failed + $0.running + $0.queued + } ?? 0 let existingHasDetail = !metrics.isEmpty || !rows.isEmpty || progress.map { $0.passed + $0.failed + $0.running + $0.queued > 0 } == true let incomingHasDetail = !incoming.metrics.isEmpty || !incoming.rows.isEmpty - || incoming.progress.map { $0.passed + $0.failed + $0.running + $0.queued > 0 } == true + || incomingProgressTotal > 0 let preservesEarlierDetail = existingHasDetail && (!incomingHasDetail || incoming.degradedReason != nil) @@ -577,7 +580,7 @@ struct WorkAdeCardModel: Identifiable, Equatable { subtitle: incoming.subtitle ?? subtitle, metrics: preservesEarlierDetail && incoming.metrics.isEmpty ? metrics : incoming.metrics, rows: preservesEarlierDetail && incoming.rows.isEmpty ? rows : incoming.rows, - progress: preservesEarlierDetail && incoming.progress == nil ? progress : incoming.progress, + progress: preservesEarlierDetail && incomingProgressTotal == 0 ? progress : incoming.progress, navTarget: incoming.navTarget ?? navTarget, actions: incoming.actions.isEmpty ? actions : incoming.actions, durationMs: incoming.durationMs ?? durationMs, diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index aff7cb8f3..3154636d2 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -7482,6 +7482,47 @@ final class ADETests: XCTestCase { XCTAssertEqual(recovered.rowsTruncated, 0) } + func testAdeCardPreservesKnownProgressAcrossDegradedZeroUpdate() throws { + let detailedPayload = try JSONDecoder().decode( + AgentChatAdeCardPayload.self, + from: Data(""" + { + "cardId": "ci-927", + "variant": "pr_ci", + "state": "terminal", + "title": "Checks", + "fallbackText": "Checks failed", + "rows": [{"icon": "fail", "text": "test-desktop"}], + "progress": {"passed": 28, "failed": 2, "running": 0, "queued": 0} + } + """.utf8) + ) + let degradedPayload = try JSONDecoder().decode( + AgentChatAdeCardPayload.self, + from: Data(""" + { + "cardId": "ci-927", + "variant": "pr_ci", + "state": "terminal", + "title": "Checks", + "fallbackText": "Checks detail unavailable", + "rows": [], + "progress": {"passed": 0, "failed": 0, "running": 0, "queued": 0}, + "degradedReason": "GitHub rate limited the detail request" + } + """.utf8) + ) + + let detailed = makeWorkAdeCardModel(from: detailedPayload) + let degraded = detailed.merging(makeWorkAdeCardModel(from: degradedPayload)) + + XCTAssertEqual(degraded.progress?.passed, 28) + XCTAssertEqual(degraded.progress?.failed, 2) + XCTAssertEqual(degraded.rows.map(\.text), ["test-desktop"]) + XCTAssertEqual(degraded.degradedReason, "GitHub rate limited the detail request") + XCTAssertEqual(degraded.isStale, true) + } + func testDatabasePersistsStableSiteIdAcrossReopen() throws { let baseURL = makeTemporaryDirectory() let database = makeDatabase(baseURL: baseURL) From 9d5861b16ec78b0a5ea706a76b4e421624f8396f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:52:17 -0400 Subject: [PATCH 08/20] Preserve shared proof during lane and transcript cleanup --- .../main/services/lanes/laneService.test.ts | 37 +++++++++++++++++++ .../src/main/services/lanes/laneService.ts | 18 +++++++-- .../chat/AgentChatMessageList.test.tsx | 27 ++++++++++++++ .../components/chat/AgentChatMessageList.tsx | 14 ++++++- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 2f324001e..07c767cde 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3295,6 +3295,43 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(fs.existsSync(sharedFile)).toBe(true); }); + it("keeps a proof file when a surviving artifact row references the same URI", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { db, service, repoRoot } = await setupWithLane({ teardown: fake, events }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" }) as any); + + const relativeUri = ".ade/artifacts/computer-use/shared-uri.png"; + const artifactFile = path.join(repoRoot, relativeUri); + fs.mkdirSync(path.dirname(artifactFile), { recursive: true }); + fs.writeFileSync(artifactFile, "shared"); + + const insertArtifact = (id: string, laneId: string) => { + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, 'proj-delete', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', ?, ?)`, + [id, id, relativeUri, laneId, "2026-03-12T14:00:00.000Z"], + ); + }; + insertArtifact("art-deleted", "lane-child"); + insertArtifact("art-survivor", "lane-parent"); + + await service.delete({ laneId: "lane-child", deleteBranch: false }); + + const remaining = db.all<{ id: string }>("select id from computer_use_artifacts").map((row) => row.id); + expect(remaining).toContain("art-survivor"); + expect(remaining).not.toContain("art-deleted"); + expect(fs.existsSync(artifactFile)).toBe(true); + }); + it("runs teardown steps before git_worktree_remove and broadcasts per-step progress", async () => { const events: any[] = []; const fake = makeFakeServices(); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 95d49f463..6cb2a8774 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3060,9 +3060,21 @@ export function createLaneService({ try { rows = db.all<{ uri: string | null }>( ` - select uri from computer_use_artifacts - where storage_kind = 'file' - and id in (${LANE_OWNED_ARTIFACT_IDS_SQL}) + with lane_owned_artifacts as ( + ${LANE_OWNED_ARTIFACT_IDS_SQL} + ) + select distinct candidate.uri + from computer_use_artifacts candidate + where candidate.storage_kind = 'file' + and candidate.id in (select id from lane_owned_artifacts) + and not exists ( + select 1 + from computer_use_artifacts survivor + where survivor.project_id = candidate.project_id + and survivor.storage_kind = 'file' + and survivor.uri = candidate.uri + and survivor.id not in (select id from lane_owned_artifacts) + ) `, [projectId, laneId, laneId, laneId], ); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index c2027d0d9..fec63b37e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -484,6 +484,33 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.getByRole("button", { name: /1 proof/ })).toBeTruthy(); }); + it("does not attribute proof older than the loaded transcript page to its first visible turn", () => { + renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "user_message", text: "Capture current proof.", turnId: "turn-visible" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:02:00.000Z", + event: { type: "done", turnId: "turn-visible", status: "completed" }, + }, + ], + { + hasOlderHistory: true, + proofArtifacts: [ + { ...transcriptProofArtifact, id: "proof-older-page", createdAt: "2026-03-17T09:30:00.000Z" }, + { ...transcriptProofArtifact, id: "proof-visible-turn", createdAt: "2026-03-17T10:01:00.000Z" }, + ], + }, + ); + + expect(screen.getByRole("button", { name: /1 proof/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /2 proof/ })).toBeNull(); + }); + it("keeps turn file-change summaries visible without a session id", () => { renderMessageList([ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 8e8f32644..b8e995758 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -6187,16 +6187,26 @@ function AgentChatMessageListMain({ .filter((entry) => Number.isFinite(entry.at)) .sort((left, right) => left.at - right.at); if (!stamped.length) return map; - let windowStart = Number.NEGATIVE_INFINITY; + const loadedTranscriptStart = allGroupedRows.reduce((earliest, env) => { + const at = Date.parse(env.timestamp); + return Number.isFinite(at) ? Math.min(earliest, at) : earliest; + }, Number.POSITIVE_INFINITY); + if (!Number.isFinite(loadedTranscriptStart)) return map; + let windowStart = loadedTranscriptStart; + let firstWindow = true; for (const env of allGroupedRows) { if (env.event.type !== "done") continue; const endMs = Date.parse(env.timestamp); if (!Number.isFinite(endMs)) continue; const captured = stamped - .filter((entry) => entry.at > windowStart && entry.at <= endMs) + .filter((entry) => ( + (firstWindow ? entry.at >= windowStart : entry.at > windowStart) + && entry.at <= endMs + )) .map((entry) => entry.artifact); if (captured.length > 0) map.set(env.key, captured); windowStart = endMs; + firstWindow = false; } return map; }, [allGroupedRows, proofArtifacts]); From 81b36c827eb42abae2c3b837c75d77237a78185f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:10:38 -0400 Subject: [PATCH 09/20] Transfer proof ownership and recover healthy TUI cards --- .../src/tuiClient/__tests__/format.test.ts | 1 - apps/ade-cli/src/tuiClient/format.ts | 6 ++- .../main/services/lanes/laneService.test.ts | 14 ++++-- .../src/main/services/lanes/laneService.ts | 49 +++++++++++++++++-- 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 38c102278..5f505b7d9 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -1469,7 +1469,6 @@ describe("ade_card (TUI)", () => { env("2026-07-27T12:02:00.000Z", 3, card({ rows: [{ icon: "pass", text: "tests", detail: "passed" }], progress: { passed: 1, failed: 0, running: 0, queued: 0 }, - degradedReason: null, })), ], }).at(-1)!.body; diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 9434053e2..8a4833891 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -70,7 +70,11 @@ function mergeAdeCardPayload(existing: AdeCardPayload, incoming: AdeCardPayload) return merged; } - if (incomingHasDetail) merged.stale = incoming.stale ?? false; + if (incomingHasDetail) { + merged.stale = incoming.stale ?? false; + merged.degradedReason = incoming.degradedReason ?? undefined; + merged.actions = incoming.actions ?? []; + } return merged; } diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 07c767cde..20540d765 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3242,7 +3242,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { return { db, service, repoRoot, worktreesDir, childPath }; } - it("deletes the lane's proof artifacts and their files, sparing proof shared with another lane", async () => { + it("transfers shared proof ownership so the final owning lane can delete it", async () => { const events: any[] = []; const fake = makeFakeServices(); const { db, service, repoRoot } = await setupWithLane({ teardown: fake, events }); @@ -3289,10 +3289,18 @@ describe("laneService delete teardown + cancellation + streaming", () => { await service.delete({ laneId: "lane-child", deleteBranch: false }); - const remaining = db.all<{ id: string }>("select id from computer_use_artifacts").map((row) => row.id); - expect(remaining).toEqual(["art-shared"]); + const remaining = db.all<{ id: string; lane_id: string | null }>( + "select id, lane_id from computer_use_artifacts", + ); + expect(remaining).toEqual([{ id: "art-shared", lane_id: "lane-parent" }]); expect(fs.existsSync(ownedFile)).toBe(false); expect(fs.existsSync(sharedFile)).toBe(true); + + await service.delete({ laneId: "lane-parent", deleteBranch: false }); + + const afterFinalOwnerDelete = db.all<{ id: string }>("select id from computer_use_artifacts"); + expect(afterFinalOwnerDelete).toEqual([]); + expect(fs.existsSync(sharedFile)).toBe(false); }); it("keeps a proof file when a surviving artifact row references the same URI", async () => { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 6cb2a8774..a9002b699 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3165,10 +3165,51 @@ export function createLaneService({ `, [projectId, laneId, laneId, laneId], ); - // Proof captured in this lane goes with the lane. Both statements have to - // run while `terminal_sessions` still exists: the guard that spares a - // capture shared with a chat in another lane joins that table, and the - // session delete below is what would otherwise orphan the links. + // Proof captured in this lane goes with the lane. Transfer shared captures + // to a surviving chat's lane before deleting this lane's ownership; the + // later delete can then collect the capture when its final lane is removed. + // This has to run while `terminal_sessions` still exists because both the + // transfer and the guard that spares shared captures join that table. + db.run( + ` + update computer_use_artifacts as a + set lane_id = ( + select s.lane_id + from computer_use_artifact_links l + join terminal_sessions s on s.id = l.owner_id + join lanes surviving_lane on surviving_lane.id = s.lane_id + where l.artifact_id = a.id + and l.owner_kind = 'chat_session' + and s.lane_id is not null + and s.lane_id <> ? + and surviving_lane.project_id = ? + order by l.created_at asc, s.id asc + limit 1 + ) + where a.project_id = ? + and ( + a.lane_id = ? + or exists ( + select 1 from computer_use_artifact_links owned_link + where owned_link.artifact_id = a.id + and owned_link.owner_kind = 'lane' + and owned_link.owner_id = ? + ) + ) + and exists ( + select 1 + from computer_use_artifact_links surviving_link + join terminal_sessions surviving_session on surviving_session.id = surviving_link.owner_id + join lanes surviving_lane on surviving_lane.id = surviving_session.lane_id + where surviving_link.artifact_id = a.id + and surviving_link.owner_kind = 'chat_session' + and surviving_session.lane_id is not null + and surviving_session.lane_id <> ? + and surviving_lane.project_id = ? + ) + `, + [laneId, projectId, projectId, laneId, laneId, laneId, projectId], + ); db.run(`delete from computer_use_artifacts where id in (${LANE_OWNED_ARTIFACT_IDS_SQL})`, [ projectId, laneId, laneId, laneId, ]); From 978f568a6908c4ff58c78595c3b43be76f5186c1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:30:36 -0400 Subject: [PATCH 10/20] Rollback rejected proof batches after rebase --- .gitleaksignore | 2 +- .../computerUseArtifactBrokerService.test.ts | 30 +++++++++ .../computerUseArtifactBrokerService.ts | 63 ++++++++++++++++--- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index 445dd85a2..72f2d4162 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -26,4 +26,4 @@ # Synthetic filename used to prove files under `.ade/secrets` cannot be # imported as proof artifacts. No credential value is present; keep the # exception scoped to the original PR commit and exact test finding. -882f4698208467280fc85e7de7f03f8953ed785b:apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts:generic-api-key:423 +631f4066397cd4f98b095d3f7d3a43d0cf758805:apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts:generic-api-key:423 diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index 9083d16f2..72a4f595b 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -298,6 +298,36 @@ describe("computerUseArtifactBrokerService", () => { expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); }); + it("removes every staged file when a later batch input fails validation", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const firstCapture = path.join(projectRoot, "first.png"); + const secondCapture = path.join(projectRoot, "second.png"); + const rejectedCapture = path.join(projectRoot, "rejected.bin"); + fs.writeFileSync(firstCapture, "first", "utf8"); + fs.writeFileSync(secondCapture, "second", "utf8"); + fs.writeFileSync(rejectedCapture, "rejected", "utf8"); + + expect(() => + broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [ + { kind: "screenshot", title: "First proof", path: firstCapture }, + { kind: "screenshot", title: "Second proof", path: secondCapture }, + { kind: "screenshot", title: "Rejected proof", path: rejectedCapture }, + ], + }), + ).toThrow(/not importable as proof/); + + const stagedDir = path.join(projectRoot, ".ade", "artifacts", "computer-use"); + expect(fs.existsSync(stagedDir) ? fs.readdirSync(stagedDir) : []).toEqual([]); + expect(broker.listArtifacts({ limit: 50 })).toHaveLength(0); + }); + it("deletes an artifact's rows and its stored file, and stays idempotent", () => { const broker = createComputerUseArtifactBrokerService({ db, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index b11843265..a5fb27833 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -88,6 +88,13 @@ type ComputerUseArtifactRecordInsert = Omit { + const materializeInlineContent = ( + input: ComputerUseArtifactInput, + kind: ComputerUseArtifactKind, + title: string, + ): { uri: string; stagedFilePath: string } => { const extension = inferArtifactExtension(input, kind); const artifactPath = createComputerUseArtifactPath(projectRoot, title, extension); if (input.json != null) { @@ -355,7 +366,10 @@ export function createComputerUseArtifactBrokerService(args: { } else { writeTextAtomic(artifactPath, input.text ?? ""); } - return toProjectArtifactUri(projectRoot, artifactPath); + return { + uri: toProjectArtifactUri(projectRoot, artifactPath), + stagedFilePath: artifactPath, + }; }; /** @@ -378,10 +392,15 @@ export function createComputerUseArtifactBrokerService(args: { kind: ComputerUseArtifactKind, title: string, callerRoot: string | null, - ): { uri: string; storageKind: "file" | "url"; mimeType: string | null } => { + ): ResolvedStoredArtifact => { const directUri = toOptionalString(input.uri); if (directUri && isHttpUrl(directUri)) { - return { uri: directUri, storageKind: "url", mimeType: toOptionalString(input.mimeType) }; + return { + uri: directUri, + storageKind: "url", + mimeType: toOptionalString(input.mimeType), + stagedFilePath: null, + }; } const pathLike = toOptionalString(input.path) ?? (directUri && !isHttpUrl(directUri) ? directUri : null); @@ -437,6 +456,7 @@ export function createComputerUseArtifactBrokerService(args: { uri: toProjectArtifactUri(projectRoot, existingArtifactPath), storageKind: "file", mimeType: toOptionalString(input.mimeType), + stagedFilePath: null, }; } catch { // Fall through to external import handling. @@ -451,13 +471,16 @@ export function createComputerUseArtifactBrokerService(args: { uri: toProjectArtifactUri(projectRoot, targetPath), storageKind: "file", mimeType: toOptionalString(input.mimeType), + stagedFilePath: targetPath, }; } + const materialized = materializeInlineContent(input, kind, title); return { - uri: materializeInlineContent(input, kind, title), + uri: materialized.uri, storageKind: "file", mimeType: toOptionalString(input.mimeType), + stagedFilePath: materialized.stagedFilePath, }; }; @@ -916,11 +939,31 @@ export function createComputerUseArtifactBrokerService(args: { // now throws (missing file, non-importable type, denied source), and a // half-committed batch would leave the agent's retry inserting the // already-stored inputs a second time. - const resolved = request.inputs.map((input) => { - const kind = normalizeInputKind(input); - const title = toOptionalString(input.title) ?? defaultTitleForKind(kind); - return { input, kind, title, stored: resolveStoredUri(input, kind, title, callerRoot) }; - }); + const resolved: Array<{ + input: ComputerUseArtifactInput; + kind: ComputerUseArtifactKind; + title: string; + stored: ResolvedStoredArtifact; + }> = []; + const stagedFilePaths: string[] = []; + try { + for (const input of request.inputs) { + const kind = normalizeInputKind(input); + const title = toOptionalString(input.title) ?? defaultTitleForKind(kind); + const stored = resolveStoredUri(input, kind, title, callerRoot); + if (stored.stagedFilePath) stagedFilePaths.push(stored.stagedFilePath); + resolved.push({ input, kind, title, stored }); + } + } catch (error) { + for (const stagedFilePath of stagedFilePaths) { + try { + fs.rmSync(stagedFilePath, { force: true }); + } catch { + // Preserve the validation error; cleanup is best-effort. + } + } + throw error; + } const artifacts = resolved.map(({ input, kind, title, stored }) => { const { uri, storageKind, mimeType } = stored; const metadata = { From 4bcf580a960cc65b8646803116673a1393fd6230 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:51:19 -0400 Subject: [PATCH 11/20] Make proof recovery and deletion retry-safe --- .../computerUseArtifactBrokerService.test.ts | 143 +++++++++++++++++- .../computerUseArtifactBrokerService.ts | 132 ++++++++++++---- 2 files changed, 246 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index 72a4f595b..99de848c0 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { openKvDb, type AdeDb } from "../state/kvDb"; import { createComputerUseArtifactBrokerService } from "./computerUseArtifactBrokerService"; @@ -413,6 +413,45 @@ describe("computerUseArtifactBrokerService", () => { expect(broker.listArtifacts({ artifactId })).toHaveLength(0); }); + it("retains the file and database rows when stored-byte deletion fails", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + owners: [{ kind: "chat_session", id: "chat-1" }], + inputs: [{ kind: "console_logs", title: "Retryable notes", text: "hello" }], + }); + const artifactId = ingested.artifacts[0]!.id; + const filePath = path.join(projectRoot, ingested.artifacts[0]!.uri); + const canonicalFilePath = fs.realpathSync(filePath); + const originalRmSync = fs.rmSync; + const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => { + if (fs.realpathSync(String(candidate)) === canonicalFilePath) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return originalRmSync(candidate, options); + }) as typeof fs.rmSync); + + try { + const result = broker.deleteArtifacts({ artifactId }); + expect(result.deleted).toEqual([]); + expect(result.failed).toEqual([ + { artifactId, reason: "permission denied" }, + ]); + expect(fs.existsSync(filePath)).toBe(true); + expect(broker.listArtifacts({ artifactId })).toHaveLength(1); + expect( + db.all(`select id from computer_use_artifact_links where artifact_id = ?`, [artifactId]), + ).toHaveLength(1); + } finally { + rmSpy.mockRestore(); + } + }); + it("prunes broken records and reports where a recoverable one still lives", () => { const broker = createComputerUseArtifactBrokerService({ db, @@ -454,6 +493,108 @@ describe("computerUseArtifactBrokerService", () => { expect(broker.listBrokenArtifacts()).toHaveLength(0); }); + it("rejects ambiguous legacy recovery when multiple lanes contain the same relative path", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const firstLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + const secondLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-b"); + for (const [root, contents] of [[firstLaneRoot, "first"], [secondLaneRoot, "second"]] as const) { + fs.mkdirSync(path.join(root, "screenshots"), { recursive: true }); + fs.writeFileSync(path.join(root, "screenshots", "result.png"), contents, "utf8"); + } + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, ?, null, ?)`, + [ + "ambiguous-legacy", + "project-1", + "Ambiguous legacy proof", + "screenshots/result.png", + JSON.stringify({ sourcePath: "screenshots/result.png" }), + "2026-03-12T14:00:00.000Z", + ], + ); + + expect(broker.listBrokenArtifacts()[0]).toMatchObject({ + artifactId: "ambiguous-legacy", + recoverablePath: null, + }); + expect(() => broker.recoverArtifact({ artifactId: "ambiguous-legacy" })) + .toThrow(/matches multiple surviving roots and cannot be recovered safely/); + expect(broker.listArtifacts({ artifactId: "ambiguous-legacy" })[0]?.uri) + .toBe("screenshots/result.png"); + }); + + it("uses an artifact's lane owner to disambiguate legacy recovery", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const firstLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + const secondLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-b"); + for (const [root, contents] of [[firstLaneRoot, "lane-a"], [secondLaneRoot, "lane-b"]] as const) { + fs.mkdirSync(path.join(root, "screenshots"), { recursive: true }); + fs.writeFileSync(path.join(root, "screenshots", "result.png"), contents, "utf8"); + } + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "lane-a", + "project-1", + "Lane A", + "main", + "feature/lane-a", + firstLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, ?, null, ?)`, + [ + "owned-legacy", + "project-1", + "Owned legacy proof", + "screenshots/result.png", + JSON.stringify({ sourcePath: "screenshots/result.png" }), + "2026-03-12T14:00:00.000Z", + ], + ); + db.run( + `insert into computer_use_artifact_links( + id, artifact_id, project_id, owner_kind, owner_id, relation, metadata_json, created_at + ) values (?, ?, ?, 'lane', ?, 'attached_to', null, ?)`, + [ + "owned-legacy-link", + "owned-legacy", + "project-1", + "lane-a", + "2026-03-12T14:00:00.000Z", + ], + ); + + const recovered = broker.recoverArtifact({ artifactId: "owned-legacy" }); + expect(recovered.availability).toBe("available"); + expect(fs.readFileSync(path.join(projectRoot, recovered.uri), "utf8")).toBe("lane-a"); + }); + it("prunes broken records that cannot be recovered", () => { const broker = createComputerUseArtifactBrokerService({ db, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index a5fb27833..45bc764cf 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -33,6 +33,7 @@ import type { AdeDb } from "../state/kvDb"; import type { SqlValue } from "../state/kvDb"; import { fileExists, + isEnoentError, isRecord, nowIso, resolvePathWithinRoot, @@ -95,6 +96,11 @@ type ResolvedStoredArtifact = { stagedFilePath: string | null; }; +type RecoverableSourceResolution = + | { status: "found"; path: string } + | { status: "missing" } + | { status: "ambiguous"; paths: string[] }; + type StoredLinkRow = { id: string; artifact_id: string; @@ -611,55 +617,114 @@ export function createComputerUseArtifactBrokerService(args: { * caller's path and root in metadata, so a capture that was never imported * can be re-imported as long as its lane worktree still exists. */ - const findRecoverableSourcePath = (record: ComputerUseArtifactRecord): string | null => { + const resolveArtifactLaneRoots = (record: ComputerUseArtifactRecord): string[] => { + const laneIds = new Set(); + if (record.laneId) laneIds.add(record.laneId); + for (const link of readLinkRows([record.id])) { + if (link.ownerKind === "lane") { + laneIds.add(link.ownerId); + continue; + } + if (link.ownerKind !== "chat_session") continue; + const session = db.get<{ lane_id: string | null }>( + "select lane_id from terminal_sessions where id = ? limit 1", + [link.ownerId], + ); + if (session?.lane_id) laneIds.add(session.lane_id); + } + if (!laneIds.size) return []; + + const placeholders = [...laneIds].map(() => "?").join(", "); + const rows = db.all<{ worktree_path: string; attached_root_path: string | null }>( + ` + select worktree_path, attached_root_path + from lanes + where project_id = ? + and id in (${placeholders}) + `, + [projectId, ...[...laneIds].sort()], + ); + return Array.from(new Set( + rows + .flatMap((row) => [row.attached_root_path, row.worktree_path]) + .filter((root): root is string => Boolean(root)) + .map((root) => path.resolve(root)), + )).sort(); + }; + + const resolveCandidateAcrossRoots = ( + candidate: string, + roots: string[], + ): RecoverableSourceResolution => { + const matches = new Set(); + for (const root of roots) { + let resolved: string; + try { + resolved = resolvePathWithinRoot(root, candidate, { allowMissing: true }); + } catch { + continue; + } + if (fileExists(resolved)) matches.add(realpathOrSelf(path.resolve(resolved))); + } + const paths = [...matches].sort(); + if (paths.length === 1) return { status: "found", path: paths[0]! }; + if (paths.length > 1) return { status: "ambiguous", paths }; + return { status: "missing" }; + }; + + const resolveRecoverableSource = (record: ComputerUseArtifactRecord): RecoverableSourceResolution => { const candidates: string[] = []; const push = (value: string | null | undefined): void => { if (!value) return; const trimmed = value.trim(); if (!trimmed || isHttpUrl(trimmed)) return; - candidates.push(trimmed); + if (!candidates.includes(trimmed)) candidates.push(trimmed); }; push(toOptionalString(record.metadata?.absolutePath)); push(toOptionalString(record.metadata?.sourcePath)); push(record.uri); const metadataCallerRoot = toOptionalString(record.metadata?.callerRoot); - const roots = [ - ...(metadataCallerRoot ? [metadataCallerRoot] : []), - projectRoot, - // Any surviving lane worktree — the capture usually came from the lane - // the agent was running in, whose name we no longer know for old rows. - ...listLaneWorktreeRoots(), - ]; + const laneRoots = resolveArtifactLaneRoots(record); + const authoritativeRoots = metadataCallerRoot + ? [path.resolve(metadataCallerRoot)] + : laneRoots.length + ? laneRoots + : null; + const fallbackRoots = [projectRoot, ...listLaneWorktreeRoots()]; for (const candidate of candidates) { if (path.isAbsolute(candidate)) { - if (fileExists(candidate)) return path.resolve(candidate); + if (fileExists(candidate)) return { status: "found", path: realpathOrSelf(path.resolve(candidate)) }; continue; } - for (const root of roots) { - let resolved: string; - try { - resolved = resolvePathWithinRoot(root, candidate, { allowMissing: true }); - } catch { - continue; - } - if (fileExists(resolved)) return resolved; + const resolution = resolveCandidateAcrossRoots( + candidate, + authoritativeRoots ?? fallbackRoots, + ); + if (resolution.status !== "missing") { + return resolution; } } - return null; + return { status: "missing" }; }; const listLaneWorktreeRoots = (): string[] => { try { return fs.readdirSync(layout.worktreesDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(layout.worktreesDir, entry.name)); + .map((entry) => path.join(layout.worktreesDir, entry.name)) + .sort(); } catch { return []; } }; + const findRecoverableSourcePath = (record: ComputerUseArtifactRecord): string | null => { + const resolution = resolveRecoverableSource(record); + return resolution.status === "found" ? resolution.path : null; + }; + const resolveAvailability = (record: ComputerUseArtifactRecord): ComputerUseArtifactAvailability => { if (record.storageKind === "url" || isHttpUrl(record.uri ?? "")) return "available"; const filePath = resolveArtifactFilePath(record); @@ -842,13 +907,17 @@ export function createComputerUseArtifactBrokerService(args: { if (filePath && !sharedReference) { try { const stat = fs.statSync(filePath); - if (stat.isFile()) { - freedBytes = stat.size; - fs.rmSync(filePath, { force: true }); - fileRemoved = true; + if (!stat.isFile()) { + throw new Error(`Artifact storage path is not a regular file: ${filePath}`); } - } catch { - // Already gone (or never written). Rows still go. + freedBytes = stat.size; + fs.rmSync(filePath, { force: true }); + fileRemoved = true; + } catch (error) { + // Absence is idempotent. Permission, lock, I/O, and other failures + // must retain both rows so the deletion can be retried rather than + // orphaning bytes that ADE can no longer manage. + if (!isEnoentError(error)) throw error; } } db.run("delete from computer_use_artifact_links where artifact_id = ?", [artifactId]); @@ -1088,12 +1157,19 @@ export function createComputerUseArtifactBrokerService(args: { if (resolveAvailability(record) === "available") { return toArtifactView(record, readLinkRows([artifactId])); } - const sourcePath = findRecoverableSourcePath(record); - if (!sourcePath) { + const source = resolveRecoverableSource(record); + if (source.status === "ambiguous") { + throw new Error( + `The original file for "${record.title}" matches multiple surviving roots and cannot be recovered safely: ` + + source.paths.join(", "), + ); + } + if (source.status === "missing") { throw new Error( `The original file for "${record.title}" no longer exists, so it cannot be recovered. Remove the record instead.`, ); } + const sourcePath = source.path; if (!isAllowedExternalArtifactSource(sourcePath, allowedImportRoots) || isDeniedArtifactSource(sourcePath, deniedImportRoots)) { throw new Error(`Artifact path is outside allowed import roots: ${sourcePath}`); From 1ae00206e1dc8a4abafbc863e0181b35ffaf7c12 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:05:08 -0400 Subject: [PATCH 12/20] Close proof security and card parity review gaps --- apps/ade-cli/src/adeRpcServer.test.ts | 134 +++++++++- apps/ade-cli/src/adeRpcServer.ts | 243 ++++++++++++++++-- .../src/tuiClient/__tests__/format.test.ts | 20 ++ apps/ade-cli/src/tuiClient/adeCardFormat.ts | 9 +- .../main/services/adeActions/registry.test.ts | 10 + .../src/main/services/adeActions/registry.ts | 13 + .../computerUseArtifactBrokerService.test.ts | 52 ++++ .../computerUseArtifactBrokerService.ts | 35 ++- .../main/services/lanes/laneService.test.ts | 61 ++++- .../src/main/services/lanes/laneService.ts | 73 +++--- .../services/projects/adeProjectService.ts | 72 +++++- .../projects/projectLifecycle.test.ts | 45 ++++ .../src/main/services/prs/prChatCards.test.ts | 6 +- .../src/main/services/prs/prChatCards.ts | 9 +- .../storage/storageInsightsService.test.ts | 34 ++- .../storage/storageInsightsService.ts | 9 +- apps/desktop/src/renderer/browserMock.test.ts | 24 ++ apps/desktop/src/renderer/browserMock.ts | 26 +- .../chat/AgentChatMessageList.test.tsx | 121 ++++++++- .../components/chat/AgentChatMessageList.tsx | 128 +++++++-- .../components/chat/AgentChatPane.test.tsx | 87 ++++++- .../components/chat/AgentChatPane.tsx | 16 +- .../components/chat/chatCardPrimitives.tsx | 22 +- .../chat/codex/CodexPlanCard.test.tsx | 33 +++ .../components/chat/codex/CodexPlanCard.tsx | 5 + .../settings/StorageSection.test.tsx | 19 ++ .../components/settings/StorageSection.tsx | 4 +- .../Views/Work/WorkChatRichCardViews.swift | 31 ++- .../ios/ADE/Views/Work/WorkEventMapping.swift | 2 +- apps/ios/ADETests/ADETests.swift | 47 ++++ 30 files changed, 1228 insertions(+), 162 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.test.tsx diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 4d23c7b1f..d07b35df8 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1288,6 +1288,78 @@ describe("adeRpcServer", () => { } }); + it("scopes proof lifecycle tools to the authenticated chat and lane owners", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-1", laneId: "lane-1" } as any); + const owned = { + id: "owned-proof", + laneId: "lane-1", + links: [], + }; + const foreign = { + id: "foreign-proof", + links: [{ ownerKind: "chat_session", ownerId: "chat-2" }], + }; + fixture.runtime.computerUseArtifactBrokerService.listArtifacts.mockImplementation((args: any) => { + if (args.artifactId === owned.id) return [owned]; + if (args.artifactId === foreign.id) return [foreign]; + if (args.ownerKind === "chat_session" && args.ownerId === "chat-1") return [owned]; + return []; + }); + fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts = vi.fn(() => ({ + deleted: [], + missing: [], + failed: [], + freedBytes: 0, + })); + fixture.runtime.computerUseArtifactBrokerService.listBrokenArtifacts = vi.fn(() => []); + fixture.runtime.computerUseArtifactBrokerService.recoverArtifact = vi.fn(); + fixture.runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts = vi.fn(); + await initialize(handler, { + callerId: "chat-1", + role: "agent", + chatSessionId: "chat-1", + }); + + const foreignOwnerIngest = await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + inputs: [{ kind: "screenshot", title: "Foreign owner", path: "proof.png" }], + owners: [{ kind: "chat_session", id: "chat-2" }], + }); + expect(foreignOwnerIngest.isError).toBe(true); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); + + await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + inputs: [{ kind: "screenshot", title: "Published proof", path: "proof.png" }], + owners: [{ kind: "github_pr", id: "https://github.com/arul28/ADE/pull/933" }], + }); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledTimes(1); + + const listed = await callTool(handler, "list_computer_use_artifacts", {}); + expect(listed.structuredContent.artifacts).toEqual([owned]); + + const foreignList = await callTool(handler, "list_computer_use_artifacts", { + ownerKind: "chat_session", + ownerId: "chat-2", + }); + expect(foreignList.isError).toBe(true); + + const foreignDelete = await callTool(handler, "delete_computer_use_artifacts", { + artifactId: foreign.id, + }); + expect(foreignDelete.isError).toBe(true); + expect(fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts).not.toHaveBeenCalled(); + + await callTool(handler, "delete_computer_use_artifacts", { artifactId: owned.id }); + expect(fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts).toHaveBeenCalledWith({ + artifactIds: [owned.id], + }); + }); + it("caps a session-bound CTO caller and scopes lifecycle actions to its own session", async () => { await withEnv({ ADE_DEFAULT_ROLE: "cto", ADE_CHAT_SESSION_ID: undefined }, async () => { const { runtime } = createRuntime(); @@ -1759,10 +1831,14 @@ describe("adeRpcServer", () => { it("auto-links computer-use ingestion to standalone chat sessions", async () => { const { runtime } = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + const laneRoot = runtime.laneService.getLaneWorktreePath("lane-1"); + fs.mkdirSync(laneRoot, { recursive: true }); + runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); await initialize(handler, { callerId: "chat-session-1", role: "agent", + chatSessionId: "chat-session-1", }); await callTool(handler, "ingest_computer_use_artifacts", { @@ -1772,7 +1848,7 @@ describe("adeRpcServer", () => { { kind: "screenshot", title: "Chat proof", - path: "/tmp/chat-proof.png", + path: path.join(laneRoot, "chat-proof.png"), }, ], }); @@ -1796,9 +1872,15 @@ describe("adeRpcServer", () => { it("forwards the caller's root so relative capture paths resolve in the agent's lane worktree", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - const laneRoot = path.join(fixture.runtime.projectRoot, ".ade", "worktrees", "lane-a"); + const laneRoot = fixture.runtime.laneService.getLaneWorktreePath("lane-1"); + fs.mkdirSync(laneRoot, { recursive: true }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); - await initialize(handler, { callerId: "chat-session-1", role: "agent" }); + await initialize(handler, { + callerId: "chat-session-1", + role: "agent", + chatSessionId: "chat-session-1", + }); await callTool(handler, "ingest_computer_use_artifacts", { backendStyle: "manual", backendName: "ade-cli", @@ -1807,15 +1889,21 @@ describe("adeRpcServer", () => { }); expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( - expect.objectContaining({ callerRoot: laneRoot }), + expect.objectContaining({ callerRoot: fs.realpathSync(laneRoot) }), ); }); it("rejects a relative caller root, which would resolve differently on each side", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fs.mkdirSync(fixture.runtime.laneService.getLaneWorktreePath("lane-1"), { recursive: true }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); - await initialize(handler, { callerId: "chat-session-1", role: "agent" }); + await initialize(handler, { + callerId: "chat-session-1", + role: "agent", + chatSessionId: "chat-session-1", + }); const response = await callTool(handler, "ingest_computer_use_artifacts", { backendStyle: "manual", backendName: "ade-cli", @@ -1828,6 +1916,42 @@ describe("adeRpcServer", () => { expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); }); + it("rejects caller roots and lane ids outside the server-authorized chat lane", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fs.mkdirSync(fixture.runtime.laneService.getLaneWorktreePath("lane-1"), { recursive: true }); + const lane2Root = fixture.runtime.laneService.getLaneWorktreePath("lane-2"); + fs.mkdirSync(lane2Root, { recursive: true }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); + await initialize(handler, { + callerId: "chat-session-1", + role: "agent", + chatSessionId: "chat-session-1", + }); + + for (const args of [ + { + callerRoot: lane2Root, + inputs: [{ kind: "screenshot", title: "Wrong root", path: "shots/proof.png" }], + }, + { + laneId: "lane-2", + inputs: [{ kind: "screenshot", title: "Wrong lane", path: "shots/proof.png" }], + }, + { + inputs: [{ kind: "screenshot", title: "Wrong absolute path", path: path.join(lane2Root, "proof.png") }], + }, + ]) { + const response = await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + ...args, + }); + expect(response.isError).toBe(true); + } + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); + }); + it("rejects standalone chat calls to ADE spawn_agent", async () => { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index ae75dc5b7..98a291a77 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2093,6 +2093,123 @@ function resolveLaneWorktreePath(runtime: AdeRuntime, laneId: string | null | un return null; } +function canonicalAuthorizationPath(candidate: string): string { + const resolved = path.resolve(candidate); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + +function resolveAuthorizedComputerUseIngestRoot( + runtime: AdeRuntime, + session: SessionState, + toolArgs: Record, +): { laneId: string | null; root: string } { + const requestedLaneId = asOptionalTrimmedString(toolArgs.laneId); + const sessionLaneId = resolveChatSessionLaneId(runtime, session); + const projectWideAuthorized = session.identity.role === "cto" && isUserClientSession(session); + if (!projectWideAuthorized && requestedLaneId && requestedLaneId !== sessionLaneId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "laneId must match the caller's authorized chat-session lane", + ); + } + const authorizedLaneId = requestedLaneId ?? sessionLaneId; + const authorizedRoot = authorizedLaneId + ? resolveLaneWorktreePath(runtime, authorizedLaneId) + : projectWideAuthorized + ? runtime.projectRoot + : null; + if (!authorizedRoot) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "Computer-use ingestion requires an authorized lane worktree.", + ); + } + const callerRoot = asOptionalTrimmedString(toolArgs.callerRoot); + if (callerRoot && !path.isAbsolute(callerRoot)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "callerRoot must be an absolute path"); + } + if ( + callerRoot + && canonicalAuthorizationPath(callerRoot) !== canonicalAuthorizationPath(authorizedRoot) + ) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "callerRoot must match the server-authorized lane worktree", + ); + } + return { laneId: authorizedLaneId, root: canonicalAuthorizationPath(authorizedRoot) }; +} + +function isProjectWideProofMaintenanceAuthorized(session: SessionState): boolean { + return session.identity.role === "cto" && isUserClientSession(session); +} + +function resolveAuthorizedProofOwners( + runtime: AdeRuntime, + session: SessionState, +): ComputerUseArtifactOwner[] { + const owners: ComputerUseArtifactOwner[] = []; + const add = (kind: ComputerUseArtifactOwner["kind"], id: string | null | undefined) => { + const normalizedId = asOptionalTrimmedString(id); + if (!normalizedId) return; + if (owners.some((owner) => owner.kind === kind && owner.id === normalizedId)) return; + owners.push({ kind, id: normalizedId, relation: "attached_to" }); + }; + add("chat_session", session.identity.chatSessionId); + add("lane", resolveChatSessionLaneId(runtime, session)); + add("automation_run", session.identity.runId); + return owners; +} + +function validateComputerUseOwnerClaims( + runtime: AdeRuntime, + session: SessionState, + toolArgs: Record, +): void { + if (isProjectWideProofMaintenanceAuthorized(session)) return; + const authorized = resolveAuthorizedProofOwners(runtime, session); + const assertAuthorized = (kind: string | null, id: string | null) => { + if (!kind || !id) return; + const normalizedKind = kind === "chat" ? "chat_session" : kind === "pr" ? "github_pr" : kind; + // Publishing proof to an explicitly named PR or Linear issue is a + // legitimate cross-surface operation. Local ownership must still come + // from the authenticated session context. + if (normalizedKind === "github_pr" || normalizedKind === "linear_issue") return; + if (!authorized.some((owner) => owner.kind === normalizedKind && owner.id === id)) { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + "Proof owner claims must match the caller's authenticated chat, lane, or automation run.", + ); + } + }; + assertAuthorized(asOptionalTrimmedString(toolArgs.ownerKind), asOptionalTrimmedString(toolArgs.ownerId)); + assertAuthorized("lane", asOptionalTrimmedString(toolArgs.laneId)); + assertAuthorized("chat_session", asOptionalTrimmedString(toolArgs.chatSessionId)); + assertAuthorized("automation_run", asOptionalTrimmedString(toolArgs.automationRunId)); + for (const entry of Array.isArray(toolArgs.owners) ? toolArgs.owners : []) { + const owner = safeObject(entry); + assertAuthorized(asOptionalTrimmedString(owner.kind), asOptionalTrimmedString(owner.id)); + } +} + +function artifactMatchesAuthorizedOwners( + artifact: { + laneId?: string | null; + links?: Array<{ ownerKind?: string; ownerId?: string }>; + } | null | undefined, + owners: ComputerUseArtifactOwner[], +): boolean { + return Boolean( + owners.some((owner) => owner.kind === "lane" && owner.id === artifact?.laneId) + || artifact?.links?.some((link) => + owners.some((owner) => owner.kind === link.ownerKind && owner.id === link.ownerId)), + ); +} + function branchNameForPrTitle(ref: string | null | undefined): string { let value = (ref ?? "").trim(); value = value.replace(/^refs\/heads\//, ""); @@ -3397,6 +3514,7 @@ async function runTool(args: { metadata: Record; toolArgs: Record; }) => { + validateComputerUseOwnerClaims(runtime, args.sessionState, args.toolArgs); const result = runtime.computerUseArtifactBrokerService.ingest({ backend: { name: "screencapture", @@ -4392,16 +4510,23 @@ async function runTool(args: { if (inputs.length === 0) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "Provide inputs for computer-use ingestion."); } - // Relative `input.path` values come from an agent whose cwd is its lane - // worktree, not the project root. Prefer an explicit callerRoot, then the - // caller's lane worktree, and only then the project root. - const callerRoot = asOptionalTrimmedString(toolArgs.callerRoot) - ?? resolveLaneWorktreePath( - runtime, - asOptionalTrimmedString(toolArgs.laneId) ?? resolveChatSessionLaneId(runtime, session), - ); - if (callerRoot && !path.isAbsolute(callerRoot)) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "callerRoot must be an absolute path"); + const authorized = resolveAuthorizedComputerUseIngestRoot(runtime, session, toolArgs); + validateComputerUseOwnerClaims(runtime, session, toolArgs); + for (const input of inputs) { + const localPath = asOptionalTrimmedString(input.path) + ?? (() => { + const uri = asOptionalTrimmedString(input.uri); + return uri && !/^https?:\/\//i.test(uri) ? uri : null; + })(); + if (!localPath || !path.isAbsolute(localPath)) continue; + try { + resolvePathWithinRoot(authorized.root, localPath, { allowMissing: true }); + } catch { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "Artifact paths must stay inside the server-authorized lane worktree", + ); + } } const result = runtime.computerUseArtifactBrokerService.ingest({ backend: { @@ -4410,7 +4535,7 @@ async function runTool(args: { toolName: asOptionalTrimmedString(toolArgs.toolName), command: asOptionalTrimmedString(toolArgs.command), }, - ...(callerRoot ? { callerRoot } : {}), + callerRoot: authorized.root, inputs: inputs.map((entry) => ({ kind: asOptionalTrimmedString(entry.kind), title: asOptionalTrimmedString(entry.title), @@ -4429,10 +4554,43 @@ async function runTool(args: { } if (name === "list_computer_use_artifacts") { + const projectWideAuthorized = isProjectWideProofMaintenanceAuthorized(session); + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + const requestedOwnerKind = asOptionalTrimmedString(toolArgs.ownerKind) as ComputerUseArtifactOwner["kind"] | null; + const requestedOwnerId = asOptionalTrimmedString(toolArgs.ownerId); + if (!projectWideAuthorized && !authorizedOwners.length) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Proof listing requires an authenticated owner scope."); + } + if ( + !projectWideAuthorized + && (requestedOwnerKind || requestedOwnerId) + && !authorizedOwners.some((owner) => owner.kind === requestedOwnerKind && owner.id === requestedOwnerId) + ) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "The requested proof owner is not authorized for this caller."); + } + if (!projectWideAuthorized) { + const limit = Math.max(1, Math.min(200, Math.floor(asNumber(toolArgs.limit, 50)))); + const kind = asOptionalTrimmedString(toolArgs.kind) as any; + const artifacts = new Map(); + const owners = requestedOwnerKind && requestedOwnerId + ? [{ kind: requestedOwnerKind, id: requestedOwnerId }] + : authorizedOwners; + for (const owner of owners) { + for (const artifact of runtime.computerUseArtifactBrokerService.listArtifacts({ + ownerKind: owner.kind, + ownerId: owner.id, + kind, + limit, + })) { + artifacts.set(artifact.id, artifact); + } + } + return { artifacts: [...artifacts.values()].slice(0, limit) }; + } return { artifacts: runtime.computerUseArtifactBrokerService.listArtifacts({ - ownerKind: asOptionalTrimmedString(toolArgs.ownerKind) as any, - ownerId: asOptionalTrimmedString(toolArgs.ownerId), + ownerKind: requestedOwnerKind as any, + ownerId: requestedOwnerId, kind: asOptionalTrimmedString(toolArgs.kind) as any, limit: asNumber(toolArgs.limit, 50), }), @@ -4449,24 +4607,73 @@ async function runTool(args: { if (!ids.length) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "Provide artifactId or artifactIds to delete."); } + if (!isProjectWideProofMaintenanceAuthorized(session)) { + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + if (!authorizedOwners.length) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Proof deletion requires an authenticated owner scope."); + } + for (const artifactId of ids) { + const artifact = runtime.computerUseArtifactBrokerService.listArtifacts({ artifactId })[0] ?? null; + if (artifact && !artifactMatchesAuthorizedOwners(artifact, authorizedOwners)) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Artifact is not owned by this caller."); + } + } + } return runtime.computerUseArtifactBrokerService.deleteArtifacts({ artifactIds: ids }); } if (name === "list_broken_computer_use_artifacts") { + const requestedLimit = Math.max(1, Math.min(2000, Math.floor(asNumber(toolArgs.limit, 200)))); + const broken = runtime.computerUseArtifactBrokerService.listBrokenArtifacts({ + limit: isProjectWideProofMaintenanceAuthorized(session) ? requestedLimit : 2000, + }); + if (!isProjectWideProofMaintenanceAuthorized(session)) { + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + if (!authorizedOwners.length) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Broken-proof listing requires an authenticated owner scope."); + } + return { + broken: broken.filter((entry) => { + const artifact = runtime.computerUseArtifactBrokerService.listArtifacts({ artifactId: entry.artifactId })[0] ?? null; + return artifactMatchesAuthorizedOwners(artifact, authorizedOwners); + }).slice(0, requestedLimit), + }; + } return { - broken: runtime.computerUseArtifactBrokerService.listBrokenArtifacts({ - limit: asNumber(toolArgs.limit, 200), - }), + broken, }; } if (name === "prune_broken_computer_use_artifacts") { - return runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts(); + if (isProjectWideProofMaintenanceAuthorized(session)) { + return runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts(); + } + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + if (!authorizedOwners.length) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Broken-proof pruning requires an authenticated owner scope."); + } + const artifactIds = runtime.computerUseArtifactBrokerService.listBrokenArtifacts({ limit: 2000 }) + .filter((entry) => { + const artifact = runtime.computerUseArtifactBrokerService.listArtifacts({ artifactId: entry.artifactId })[0] ?? null; + return artifactMatchesAuthorizedOwners(artifact, authorizedOwners); + }) + .map((entry) => entry.artifactId); + return artifactIds.length + ? runtime.computerUseArtifactBrokerService.deleteArtifacts({ artifactIds }) + : { deleted: [], missing: [], failed: [], freedBytes: 0 }; } if (name === "recover_computer_use_artifact") { + const artifactId = assertNonEmptyString(toolArgs.artifactId, "artifactId"); + if (!isProjectWideProofMaintenanceAuthorized(session)) { + const authorizedOwners = resolveAuthorizedProofOwners(runtime, session); + const artifact = runtime.computerUseArtifactBrokerService.listArtifacts({ artifactId })[0] ?? null; + if (!authorizedOwners.length || (artifact && !artifactMatchesAuthorizedOwners(artifact, authorizedOwners))) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Artifact is not owned by this caller."); + } + } return runtime.computerUseArtifactBrokerService.recoverArtifact({ - artifactId: assertNonEmptyString(toolArgs.artifactId, "artifactId"), + artifactId, }); } diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 5f505b7d9..175f4952a 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -1495,4 +1495,24 @@ describe("ade_card (TUI)", () => { expect(body).toContain("report.md"); expect(body).toContain("+4 more"); }); + + it("counts emitter-truncated rows and rows hidden by the local cap", () => { + const body = renderChatLines({ + activeSession: null, + notices: [], + events: [ + env("2026-07-27T12:00:00.000Z", 1, card({ + rows: Array.from({ length: 7 }, (_, index) => ({ + icon: "file", + text: `report-${index + 1}.md`, + })), + rowsTruncated: 3, + })), + ], + }).at(-1)!.body; + + expect(body).toContain("report-5.md"); + expect(body).not.toContain("report-6.md"); + expect(body).toContain("+5 more"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/adeCardFormat.ts b/apps/ade-cli/src/tuiClient/adeCardFormat.ts index 949709fa2..62ae18c15 100644 --- a/apps/ade-cli/src/tuiClient/adeCardFormat.ts +++ b/apps/ade-cli/src/tuiClient/adeCardFormat.ts @@ -116,15 +116,18 @@ export function renderAdeCardBody(card: AdeCardPayload): string { lines.push(adeCardBoxRow(statusMeta.join(" · "))); } - const rows = (card.rows ?? []).slice(0, 5); + const allRows = card.rows ?? []; + const rows = allRows.slice(0, 5); + const hiddenRows = Math.max(0, card.rowsTruncated ?? 0) + + Math.max(0, allRows.length - rows.length); if (rows.length) { lines.push(adeCardBoxRow("")); for (const row of rows) { const rowGlyph = row.icon ? ADE_CARD_ROW_GLYPHS[row.icon] ?? "·" : "·"; lines.push(adeCardBoxRow(`${rowGlyph} ${row.text}`, row.detail?.trim() ?? "")); } - if ((card.rowsTruncated ?? 0) > 0) { - lines.push(adeCardBoxRow(`+${card.rowsTruncated} more`)); + if (hiddenRows > 0) { + lines.push(adeCardBoxRow(`+${hiddenRows} more`)); } } diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index f31071721..7a11dc80a 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -370,6 +370,16 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { const actions = ADE_ACTION_ALLOWLIST.computer_use_artifacts ?? []; expect(actions).toContain("getBackendStatus"); expect(actions).toContain("readArtifactPreview"); + for (const action of [ + "deleteArtifacts", + "ingest", + "listArtifacts", + "listBrokenArtifacts", + "pruneBrokenArtifacts", + "recoverArtifact", + ]) { + expect(isCtoOnlyAdeAction("computer_use_artifacts", action)).toBe(true); + } }); it("exposes prompt stashes through the project runtime for connected desktops", () => { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index fc9459f77..6757b42aa 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -237,6 +237,19 @@ export const ADE_ACTION_CTO_ONLY: Partial = { diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index 99de848c0..768b75654 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -100,6 +100,28 @@ describe("computerUseArtifactBrokerService", () => { ]); }); + it("lists legacy lane-owned artifacts that have lane_id but no owner link", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values ('lane-only', 'project-1', 'screenshot', 'manual', 'ade-cli', null, null, + 'Lane only', null, '.ade/artifacts/computer-use/lane-only.png', 'file', 'image/png', + '{}', 'lane-1', '2026-03-12T14:00:00.000Z')`, + ); + + expect(broker.listArtifacts({ ownerKind: "lane", ownerId: "lane-1" })) + .toEqual([expect.objectContaining({ id: "lane-only", laneId: "lane-1" })]); + expect(broker.listArtifacts({ ownerKind: "lane", ownerId: "lane-2" })).toEqual([]); + }); + it("reads image previews only from the project artifact directory", async () => { const broker = createComputerUseArtifactBrokerService({ db, @@ -393,6 +415,36 @@ describe("computerUseArtifactBrokerService", () => { expect(fs.existsSync(filePath)).toBe(false); }); + it("keeps shared stored bytes when surviving records use an equivalent URI spelling", () => { + const canonicalProjectRoot = fs.realpathSync(projectRoot); + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot: canonicalProjectRoot, + logger: createLogger(), + }); + const first = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Shared aliases", text: "hello" }], + }).artifacts[0]!; + const filePath = path.join(canonicalProjectRoot, first.uri); + const second = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + inputs: [{ kind: "console_logs", title: "Shared aliases again", path: filePath }], + }).artifacts[0]!; + db.run( + "update computer_use_artifacts set uri = ? where id = ?", + [`ade-artifact://project/${first.uri}`, second.id], + ); + + expect(broker.deleteArtifacts({ artifactId: first.id }).deleted[0]).toMatchObject({ + artifactId: first.id, + fileRemoved: false, + }); + expect(fs.existsSync(filePath)).toBe(true); + expect(broker.listArtifacts({ artifactId: second.id })[0]?.availability).toBe("available"); + }); + it("removes rows for records whose file was already deleted", () => { const broker = createComputerUseArtifactBrokerService({ db, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 45bc764cf..53e746680 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -387,10 +387,7 @@ export function createComputerUseArtifactBrokerService(args: { * `.ade/artifacts`. */ const relativeResolutionRoots = (callerRoot: string | null): string[] => { - const roots: string[] = []; - if (callerRoot) roots.push(path.resolve(callerRoot)); - if (!roots.includes(path.resolve(projectRoot))) roots.push(path.resolve(projectRoot)); - return roots; + return [path.resolve(callerRoot ?? projectRoot)]; }; const resolveStoredUri = ( @@ -890,20 +887,18 @@ export function createComputerUseArtifactBrokerService(args: { const filePath = resolveArtifactFilePath(record); let fileRemoved = false; let freedBytes = 0; - const sharedReference = record.storageKind === "file" - ? db.get<{ id: string }>( + const sharedReference = filePath && record.storageKind === "file" + ? readArtifactRows( ` - select id + select ${ARTIFACT_SELECT_COLUMNS} from computer_use_artifacts where project_id = ? and storage_kind = 'file' - and uri = ? and id <> ? - limit 1 `, - [projectId, record.uri, artifactId], - ) - : null; + [projectId, artifactId], + ).some((candidate) => resolveArtifactFilePath(candidate) === filePath) + : false; if (filePath && !sharedReference) { try { const stat = fs.statSync(filePath); @@ -1092,23 +1087,25 @@ export function createComputerUseArtifactBrokerService(args: { if (ownerKind && ownerId) { artifacts = readArtifactRows( ` - select a.id, a.artifact_kind, a.backend_style, a.backend_name, a.source_tool_name, + select distinct a.id, a.artifact_kind, a.backend_style, a.backend_name, a.source_tool_name, a.original_type, a.title, a.description, a.uri, a.storage_kind, a.mime_type, a.metadata_json, a.lane_id, a.created_at from computer_use_artifacts a - inner join computer_use_artifact_links l + left join computer_use_artifact_links l on l.artifact_id = a.id + and l.project_id = ? where a.project_id = ? - and l.project_id = ? - and l.owner_kind = ? - and l.owner_id = ? + and ( + (l.owner_kind = ? and l.owner_id = ?) + or (? = 'lane' and a.lane_id = ?) + ) ${args.kind ? "and a.artifact_kind = ?" : ""} order by a.created_at desc limit ? `, args.kind - ? [projectId, projectId, ownerKind, ownerId, args.kind, limit] - : [projectId, projectId, ownerKind, ownerId, limit], + ? [projectId, projectId, ownerKind, ownerId, ownerKind, ownerId, args.kind, limit] + : [projectId, projectId, ownerKind, ownerId, ownerKind, ownerId, limit], ); } else { artifacts = readArtifactRows( diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 20540d765..dfffb6996 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3303,7 +3303,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(fs.existsSync(sharedFile)).toBe(false); }); - it("keeps a proof file when a surviving artifact row references the same URI", async () => { + it("keeps a proof file when a surviving artifact row uses an equivalent URI spelling", async () => { const events: any[] = []; const fake = makeFakeServices(); const { db, service, repoRoot } = await setupWithLane({ teardown: fake, events }); @@ -3319,18 +3319,18 @@ describe("laneService delete teardown + cancellation + streaming", () => { fs.mkdirSync(path.dirname(artifactFile), { recursive: true }); fs.writeFileSync(artifactFile, "shared"); - const insertArtifact = (id: string, laneId: string) => { + const insertArtifact = (id: string, laneId: string, uri: string) => { db.run( `insert into computer_use_artifacts( id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, original_type, title, description, uri, storage_kind, mime_type, metadata_json, lane_id, created_at ) values (?, 'proj-delete', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', ?, ?)`, - [id, id, relativeUri, laneId, "2026-03-12T14:00:00.000Z"], + [id, id, uri, laneId, "2026-03-12T14:00:00.000Z"], ); }; - insertArtifact("art-deleted", "lane-child"); - insertArtifact("art-survivor", "lane-parent"); + insertArtifact("art-deleted", "lane-child", relativeUri); + insertArtifact("art-survivor", "lane-parent", `ade-artifact://project/${relativeUri}`); await service.delete({ laneId: "lane-child", deleteBranch: false }); @@ -3340,6 +3340,57 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(fs.existsSync(artifactFile)).toBe(true); }); + it("reports only proof files actually removed when lane cleanup partially fails", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { db, service, repoRoot } = await setupWithLane({ teardown: fake, events }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" }) as any); + + const artifactsDir = path.join(repoRoot, ".ade", "artifacts", "computer-use"); + const removedFile = path.join(artifactsDir, "removed.png"); + const failedFile = path.join(artifactsDir, "failed.png"); + fs.mkdirSync(artifactsDir, { recursive: true }); + fs.writeFileSync(removedFile, "removed"); + fs.writeFileSync(failedFile, "failed"); + for (const [id, uri] of [ + ["removed", ".ade/artifacts/computer-use/removed.png"], + ["failed", ".ade/artifacts/computer-use/failed.png"], + ]) { + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, 'proj-delete', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', 'lane-child', ?)`, + [id, id, uri, "2026-03-12T14:00:00.000Z"], + ); + } + const originalRmSync = fs.rmSync; + const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => { + if (path.resolve(String(candidate)) === fs.realpathSync(failedFile)) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return originalRmSync(candidate, options); + }) as typeof fs.rmSync); + + try { + await service.delete({ laneId: "lane-child", deleteBranch: false }); + } finally { + rmSpy.mockRestore(); + } + + const last = events[events.length - 1]; + expect(last.progress.steps.find((step: any) => step.name === "database_cleanup")?.detail) + .toBe("1 of 2 proof file(s) removed; see logs"); + expect(fs.existsSync(removedFile)).toBe(false); + expect(fs.existsSync(failedFile)).toBe(true); + }); + it("runs teardown steps before git_worktree_remove and broadcasts per-step progress", async () => { const events: any[] = []; const fake = makeFakeServices(); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index a9002b699..8e5a6d105 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3056,27 +3056,26 @@ export function createLaneService({ */ const collectLaneArtifactFilePaths = (laneId: string): string[] => { const artifactsDir = resolveAdeLayout(projectRoot).artifactsDir; - let rows: Array<{ uri: string | null }> = []; + let ownedIds: Set; + let rows: Array<{ id: string; uri: string | null }>; try { - rows = db.all<{ uri: string | null }>( + ownedIds = new Set(db.all<{ id: string }>( ` with lane_owned_artifacts as ( ${LANE_OWNED_ARTIFACT_IDS_SQL} ) - select distinct candidate.uri - from computer_use_artifacts candidate - where candidate.storage_kind = 'file' - and candidate.id in (select id from lane_owned_artifacts) - and not exists ( - select 1 - from computer_use_artifacts survivor - where survivor.project_id = candidate.project_id - and survivor.storage_kind = 'file' - and survivor.uri = candidate.uri - and survivor.id not in (select id from lane_owned_artifacts) - ) + select id from lane_owned_artifacts `, [projectId, laneId, laneId, laneId], + ).map((row) => row.id)); + rows = db.all<{ id: string; uri: string | null }>( + ` + select id, uri + from computer_use_artifacts + where project_id = ? + and storage_kind = 'file' + `, + [projectId], ); } catch (error) { logger.warn("lane.delete.artifact_paths_query_failed", { @@ -3085,37 +3084,45 @@ export function createLaneService({ }); return []; } - const paths: string[] = []; - for (const row of rows) { - const uri = typeof row.uri === "string" ? row.uri.trim() : ""; - if (!uri || /^https?:\/\//i.test(uri)) continue; + const resolveJailedArtifactPath = (rawUri: string | null): string | null => { + const uri = typeof rawUri === "string" ? rawUri.trim() : ""; + if (!uri || /^https?:\/\//i.test(uri)) return null; let relative = uri; if (/^ade-artifact:\/\/project(?:\/|$)/i.test(relative)) { try { relative = decodeURIComponent(new URL(relative).pathname.replace(/^\/+/, "")); } catch { - continue; + return null; } } const absolute = path.resolve(path.isAbsolute(relative) ? relative : path.join(projectRoot, relative)); - // Realpath, not a lexical prefix check. `uri` is a CRR-replicated column, - // so a paired peer can write it; a directory symlink under the artifacts - // dir would otherwise let this unlink walk straight out of the jail. - let jailed: string; try { - jailed = resolvePathWithinRoot(artifactsDir, absolute, { allowMissing: true }); + return resolvePathWithinRoot(artifactsDir, absolute, { allowMissing: true }); } catch { - continue; + return null; } - paths.push(jailed); + }; + const survivorPaths = new Set( + rows + .filter((row) => !ownedIds.has(row.id)) + .map((row) => resolveJailedArtifactPath(row.uri)) + .filter((candidate): candidate is string => Boolean(candidate)), + ); + const paths = new Set(); + for (const row of rows) { + if (!ownedIds.has(row.id)) continue; + const jailed = resolveJailedArtifactPath(row.uri); + if (jailed && !survivorPaths.has(jailed)) paths.add(jailed); } - return paths; + return [...paths]; }; - const removeLaneArtifactFiles = (laneId: string, filePaths: string[]): void => { + const removeLaneArtifactFiles = (laneId: string, filePaths: string[]): number => { + let removed = 0; for (const filePath of filePaths) { try { fs.rmSync(filePath, { force: true }); + removed += 1; } catch (error) { logger.warn("lane.delete.artifact_file_remove_failed", { laneId, @@ -3124,6 +3131,7 @@ export function createLaneService({ }); } } + return removed; }; const cleanupLaneDatabaseRows = (laneId: string): void => { @@ -6296,10 +6304,11 @@ export function createLaneService({ } throw error; } - removeLaneArtifactFiles(laneId, laneArtifactFiles); - return laneArtifactFiles.length - ? { detail: `${laneArtifactFiles.length} proof file(s) removed` } - : undefined; + const removedProofFiles = removeLaneArtifactFiles(laneId, laneArtifactFiles); + if (!laneArtifactFiles.length || removedProofFiles === 0) return undefined; + return removedProofFiles === laneArtifactFiles.length + ? { detail: `${removedProofFiles} proof file(s) removed` } + : { detail: `${removedProofFiles} of ${laneArtifactFiles.length} proof file(s) removed; see logs` }; }); invalidateLaneListCache(); diff --git a/apps/desktop/src/main/services/projects/adeProjectService.ts b/apps/desktop/src/main/services/projects/adeProjectService.ts index 9eaaa4275..8890d4924 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import YAML from "yaml"; import type { AdeDb } from "../state/kvDb"; import type { @@ -13,6 +14,7 @@ import type { } from "../../../shared/types"; import { buildAdeGitignore, ADE_LAYOUT_DEFINITIONS, resolveAdeLayout, type AdeLayoutPaths } from "../../../shared/adeLayout"; import type { Logger } from "../logging/logger"; +import { resolvePathWithinRoot } from "../shared/utils"; import { createLogIntegrityService, type LogIntegrityService } from "./logIntegrityService"; type RepairOptions = { @@ -462,20 +464,66 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { }; if (options.packs) { + const artifactIdsUnderArtifactsDir = (() => { + try { + const rows = args.db.all<{ id: string; uri: string }>( + "select id, uri from computer_use_artifacts where project_id = ? and storage_kind = 'file'", + [args.projectId], + ); + return rows.flatMap((row) => { + const rawUri = typeof row.uri === "string" ? row.uri.trim() : ""; + if (!rawUri || /^https?:\/\//i.test(rawUri)) return []; + let candidate = rawUri; + try { + if (/^ade-artifact:\/\/project(?:\/|$)/i.test(candidate)) { + candidate = decodeURIComponent(new URL(candidate).pathname.replace(/^\/+/, "")); + } else if (/^file:\/\//i.test(candidate)) { + candidate = fileURLToPath(candidate); + } + const absolute = path.resolve( + path.isAbsolute(candidate) ? candidate : path.join(args.projectRoot, candidate), + ); + resolvePathWithinRoot(repair.paths.artifactsDir, absolute, { allowMissing: true }); + return [row.id]; + } catch { + return []; + } + }); + } catch (error) { + args.logger?.warn("ade.project.clear_local_data.artifact_scope_failed", { + error: error instanceof Error ? error.message : String(error), + }); + throw new Error("Could not safely identify proof records stored under .ade/artifacts."); + } + })(); rmrf(repair.paths.artifactsDir); // Removing `.ade/artifacts` without the rows leaves every proof record - // pointing at bytes that no longer exist — the inverse of the orphan a - // lane delete used to create. Drop the records with the files. - try { - args.db.run( - "delete from computer_use_artifact_links where artifact_id in (select id from computer_use_artifacts where project_id = ?)", - [args.projectId], - ); - args.db.run("delete from computer_use_artifacts where project_id = ?", [args.projectId]); - } catch (error) { - args.logger?.warn("ade.project.clear_local_data.artifact_rows_failed", { - error: error instanceof Error ? error.message : String(error), - }); + // pointing at bytes that no longer exist. Delete only records whose + // canonical file path was inside that removed root; attachment-backed + // records remain valid because `.ade/attachments` is managed separately. + if (artifactIdsUnderArtifactsDir.length) { + const placeholders = artifactIdsUnderArtifactsDir.map(() => "?").join(", "); + try { + args.db.run("begin immediate"); + args.db.run( + `delete from computer_use_artifact_links where artifact_id in (${placeholders})`, + artifactIdsUnderArtifactsDir, + ); + args.db.run( + `delete from computer_use_artifacts where project_id = ? and id in (${placeholders})`, + [args.projectId, ...artifactIdsUnderArtifactsDir], + ); + args.db.run("commit"); + } catch (error) { + try { + args.db.run("rollback"); + } catch { + // Surface the original row-cleanup failure. + } + args.logger?.warn("ade.project.clear_local_data.artifact_rows_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } } } if (options.logs) rmrf(repair.paths.logsDir); diff --git a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts index 313d9abfe..370741846 100644 --- a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts +++ b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts @@ -56,6 +56,7 @@ function makeProjectConfigDb() { setJson: vi.fn((key: string, value: unknown) => { store.set(key, value); }), + all: vi.fn(() => []), run: vi.fn(), } as any; } @@ -364,6 +365,50 @@ describe("createAdeProjectService.clearLocalData", () => { expect(fs.readFileSync(path.join(layout.cacheDir, "keep.json"), "utf8")).toBe("cache"); expect(fs.readFileSync(path.join(layout.secretsDir, "keep"), "utf8")).toBe("secret"); }); + + it("preserves attachment-backed proof rows when clearing artifact packs", async () => { + const root = makeTempDir("ade-project-clear-artifact-rows-"); + const layout = resolveAdeLayout(root); + const db = await openKvDb(path.join(layout.adeDir, "ade.db"), createLogger()); + const now = "2026-07-29T00:00:00.000Z"; + insertProject(db, "project-1", root, now); + const service = createAdeProjectService({ + projectRoot: root, + db, + projectId: "project-1", + logger: createLogger(), + projectConfigService: { + get: () => ({ validation: { ok: true, issues: [] } }), + }, + }); + const artifactFile = path.join(layout.artifactsDir, "computer-use", "proof.png"); + const attachmentFile = path.join(layout.adeDir, "attachments", "prompt.png"); + fs.mkdirSync(path.dirname(artifactFile), { recursive: true }); + fs.mkdirSync(path.dirname(attachmentFile), { recursive: true }); + fs.writeFileSync(artifactFile, "proof"); + fs.writeFileSync(attachmentFile, "attachment"); + const insertArtifact = (id: string, uri: string) => { + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, 'project-1', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', 'image/png', '{}', null, ?)`, + [id, id, uri, now], + ); + }; + insertArtifact("stored-proof", "ade-artifact://project/.ade/artifacts/computer-use/proof.png"); + insertArtifact("stored-attachment", ".ade/attachments/prompt.png"); + + service.clearLocalData({ packs: true }); + + expect(fs.existsSync(artifactFile)).toBe(false); + expect(fs.existsSync(attachmentFile)).toBe(true); + expect(db.all<{ id: string }>("select id from computer_use_artifacts order by id")).toEqual([ + { id: "stored-attachment" }, + ]); + db.close(); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/prs/prChatCards.test.ts b/apps/desktop/src/main/services/prs/prChatCards.test.ts index 10e7409d3..1ec194d77 100644 --- a/apps/desktop/src/main/services/prs/prChatCards.test.ts +++ b/apps/desktop/src/main/services/prs/prChatCards.test.ts @@ -250,15 +250,17 @@ describe("PR chat cards", () => { expect(card.fallbackText).toContain("detail unavailable"); }); - it("keeps a card rich when only one of the two fetches failed", () => { + it("keeps partial rows while marking a one-endpoint failure as degraded", () => { const card = buildPrCiCard({ pr: pr({ checksStatus: "passing" }), runs: [], checks: [check("Vercel")], fetchError: "HTTP 403: API rate limit exceeded", }); - expect(card.degradedReason).toBeUndefined(); + expect(card.degradedReason).toContain("403"); expect(card.rows?.[0]?.text).toBe("Vercel"); + expect(card.actions).toEqual([{ id: "retry", label: "Retry", kind: "primary" }]); + expect(card.fallbackText).toContain("job detail unavailable in part"); }); it("counts the rows it dropped instead of silently capping at three", () => { diff --git a/apps/desktop/src/main/services/prs/prChatCards.ts b/apps/desktop/src/main/services/prs/prChatCards.ts index a1b01278c..3e7942cb7 100644 --- a/apps/desktop/src/main/services/prs/prChatCards.ts +++ b/apps/desktop/src/main/services/prs/prChatCards.ts @@ -189,9 +189,10 @@ export function buildPrCiCard(args: { ? "success" : "accent"; const total = progress.passed + progress.failed + progress.running + progress.queued + other; - // Only a zero-count card is actually degraded — if the jobs call failed but - // the checks call answered (or vice versa) we still have real detail to show. - const degraded = total === 0 && fetchError != null; + // A partial response is still degraded: the surviving endpoint's rows remain + // useful, but they are not the complete job inventory. Keep the warning and + // Retry action alongside those rows instead of presenting them as complete. + const degraded = fetchError != null; return { cardId: `pr-ci:${pr.id}:${episode}`, @@ -220,7 +221,7 @@ export function buildPrCiCard(args: { : {}), navTarget: prNavTarget(pr, "checks"), fallbackText: degraded - ? `PR #${pr.githubPrNumber} checks are ${pr.checksStatus}; job detail unavailable (${fetchError}).` + ? `PR #${pr.githubPrNumber} checks are ${pr.checksStatus}; job detail unavailable in part (${fetchError}).` : `PR #${pr.githubPrNumber} ${title.toLowerCase()}.`, }; } diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts index 1b39b5716..988fb1389 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts @@ -120,7 +120,7 @@ describe("storageInsightsService", () => { writeSized(path.join(projectRoot, ".ade", "transcripts", "chat", "one.log"), 11); writeSized(path.join(projectRoot, ".ade", "cache", "terminal-snapshots", "one.txt"), 13); writeSized(path.join(projectRoot, ".ade", "cache", "browser-observations", "cache.bin"), 7); - writeSized(path.join(projectRoot, ".ade", "artifacts", "proof.bin"), 5); + writeSized(path.join(projectRoot, ".ade", "artifacts", "computer-use", "proof.bin"), 5); writeSized(path.join(projectRoot, ".ade", "ade.db.recovery-fixture.bak"), 3); const lanePath = path.join(projectRoot, ".ade", "worktrees", "active-lane"); writeSized(path.join(lanePath, "source.bin"), 17); @@ -359,7 +359,12 @@ describe("storageInsightsService", () => { // Settings used to render this card with a size and no Remove button, // pointing the user at a proof-drawer control that did not exist. const artifactsDir = path.join(projectRoot, ".ade", "artifacts"); - writeSized(path.join(artifactsDir, "computer-use", "shot.png"), 21); + const proofDir = path.join(artifactsDir, "computer-use"); + const packsDir = path.join(artifactsDir, "packs"); + const logBundlesDir = path.join(artifactsDir, "log-bundles"); + writeSized(path.join(proofDir, "shot.png"), 21); + writeSized(path.join(packsDir, "lane-pack.tar"), 13); + writeSized(path.join(logBundlesDir, "diagnostic.log"), 8); const purged: string[] = []; const service = createStorageInsightsService({ projectRoot, @@ -369,16 +374,31 @@ describe("storageInsightsService", () => { purgeProofRecordsUnder: (removedPath) => purged.push(removedPath), }); - const targets: StorageCleanupTarget[] = [{ kind: "proof_attachments", path: artifactsDir }]; + const targets: StorageCleanupTarget[] = [{ kind: "proof_attachments", path: proofDir }]; const preview = await service.cleanupPreview(targets); expect(preview.blocked).toEqual([]); - expect(preview.items[0]).toMatchObject({ path: artifactsDir, bytes: 21, label: "Proof and recordings" }); + expect(preview.items[0]).toMatchObject({ path: proofDir, bytes: 21, label: "Proof and recordings" }); const result = await service.cleanup(targets, { preview }); - expect(result.removed).toEqual([{ path: artifactsDir, bytes: 21 }]); - expect(fs.existsSync(artifactsDir)).toBe(false); + expect(result.removed).toEqual([{ path: proofDir, bytes: 21 }]); + expect(fs.existsSync(proofDir)).toBe(false); + expect(fs.existsSync(path.join(packsDir, "lane-pack.tar"))).toBe(true); + expect(fs.existsSync(path.join(logBundlesDir, "diagnostic.log"))).toBe(true); // Rows must never outlive the bytes. - expect(purged).toEqual([artifactsDir]); + expect(purged).toEqual([proofDir]); + }); + + it("refuses proof cleanup for the shared artifacts parent", async () => { + const artifactsDir = path.join(projectRoot, ".ade", "artifacts"); + writeSized(path.join(artifactsDir, "packs", "lane-pack.tar"), 4); + const service = createStorageInsightsService({ projectRoot, adeHome, db, logger }); + + const preview = await service.cleanupPreview([{ kind: "proof_attachments", path: artifactsDir }]); + + expect(preview.items).toEqual([]); + expect(preview.blocked).toEqual([ + { path: artifactsDir, reason: "This path is not proof or attachment storage." }, + ]); }); it("refuses proof cleanup for paths outside the proof and attachment stores", async () => { diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.ts b/apps/desktop/src/main/services/storage/storageInsightsService.ts index 9be7e54b9..b1d7fa159 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.ts @@ -806,7 +806,7 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti } for (const [proofPath, label] of [ - [layout.artifactsDir, "Proof and recordings"], + [path.join(layout.artifactsDir, "computer-use"), "Proof and recordings"], [path.join(layout.adeDir, "attachments"), "Attachments"], ] as const) { add("proof_attachments", (await makeItem({ @@ -1028,11 +1028,14 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti return { valid: null, reason: "Links cannot be used in a cleanup path." }; } // Same jail the broker and the `ade-artifact://` handler enforce. - const proofRoots = [layout.artifactsDir, path.join(layout.adeDir, "attachments")]; + const proofRoots = [ + path.join(layout.artifactsDir, "computer-use"), + path.join(layout.adeDir, "attachments"), + ]; if (!proofRoots.some((root) => isSameOrWithin(root, targetPath))) { return { valid: null, reason: "This path is not proof or attachment storage." }; } - label = isSameOrWithin(layout.artifactsDir, targetPath) ? "Proof and recordings" : "Attachments"; + label = isSameOrWithin(proofRoots[0], targetPath) ? "Proof and recordings" : "Attachments"; } else { return { valid: null, reason: "This cleanup target is not supported." }; } diff --git a/apps/desktop/src/renderer/browserMock.test.ts b/apps/desktop/src/renderer/browserMock.test.ts index 178fd2211..7c9ef5788 100644 --- a/apps/desktop/src/renderer/browserMock.test.ts +++ b/apps/desktop/src/renderer/browserMock.test.ts @@ -62,3 +62,27 @@ describe("browserMock prompt stashes", () => { await expect(window.ade.agentChat.promptStashes.list()).resolves.not.toContainEqual(created); }); }); + +describe("browserMock proof contracts", () => { + it("returns complete delete, prune, and recovery results", async () => { + await expect(window.ade.computerUse.deleteArtifacts({ artifactId: "proof-1" })).resolves.toEqual({ + deleted: [], + missing: [], + failed: [], + freedBytes: 0, + }); + await expect(window.ade.computerUse.pruneBrokenArtifacts()).resolves.toEqual({ + deleted: [], + missing: [], + failed: [], + freedBytes: 0, + }); + await expect(window.ade.computerUse.recoverArtifact({ artifactId: "proof-1" })).resolves.toMatchObject({ + id: "browser-proof-recovered", + availability: "available", + links: [], + reviewState: "pending", + workflowState: "evidence_only", + }); + }); +}); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index b8e714133..5ffd9db35 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3755,10 +3755,30 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { readArtifactPreview: resolvedArg(null), // The drawer calls these directly; without them the standalone web // renderer throws a TypeError on the delete and recover controls. - deleteArtifacts: resolvedArg({ deleted: [], missing: [], failed: [] } as any), + deleteArtifacts: resolvedArg({ deleted: [], missing: [], failed: [], freedBytes: 0 }), listBrokenArtifacts: resolvedArg([]), - pruneBrokenArtifacts: resolvedArg({ deleted: [], missing: [], failed: [] } as any), - recoverArtifact: resolvedArg({} as any), + pruneBrokenArtifacts: resolvedArg({ deleted: [], missing: [], failed: [], freedBytes: 0 }), + recoverArtifact: resolvedArg({ + id: "browser-proof-recovered", + kind: "screenshot", + backendStyle: "local_fallback", + backendName: "ADE browser preview", + sourceToolName: "recover", + originalType: "image", + title: "Recovered proof", + description: null, + uri: ".ade/artifacts/browser-proof-recovered.png", + storageKind: "file", + mimeType: "image/png", + metadata: {}, + laneId: null, + createdAt: "2026-01-01T00:00:00.000Z", + links: [], + reviewState: "pending", + workflowState: "evidence_only", + reviewNote: null, + availability: "available", + }), onEvent: () => () => {}, }, onboarding: { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index fec63b37e..dbeb2c03a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -150,6 +150,7 @@ function renderMessageList( onRetryOlderHistory?: () => void; onReturnToLatest?: () => void; proofArtifacts?: ComputerUseArtifactView[]; + allowLocalProofArtifactProtocol?: boolean; onOpenProofDrawer?: () => void; }, ) { @@ -175,6 +176,7 @@ function renderMessageList( onRetryOlderHistory={options?.onRetryOlderHistory} onReturnToLatest={options?.onReturnToLatest} proofArtifacts={options?.proofArtifacts} + allowLocalProofArtifactProtocol={options?.allowLocalProofArtifactProtocol} onOpenProofDrawer={options?.onOpenProofDrawer} /> @@ -453,15 +455,15 @@ describe("AgentChatMessageList operator navigation suggestions", () => { }); describe("AgentChatMessageList transcript rendering", () => { - // Proof used to be appended after every row as a permanent thread footer, so - // it never appeared where the capture happened. It is now an inline - // chronological `ade_card` row plus a chip on the turn rule; the footer is - // gone from BOTH render paths. - it("no longer pins collected proof to the bottom of the thread", () => { + // Proof used to be appended after every row as a permanently open thread + // footer. With no transcript rows it is now a compact chronological capture + // row that starts collapsed. + it("renders proof attached to an empty chat as a collapsed capture row", () => { const rendered = renderMessageList([], { proofArtifacts: [transcriptProofArtifact] }); expect(screen.queryByText("Proof collected in this chat")).toBeNull(); expect(rendered.container.querySelector("[data-chat-proof-timeline]")).toBeNull(); + expect(screen.getByRole("button", { name: /Proof added/ }).getAttribute("aria-expanded")).toBe("false"); }); it("chips proof onto the turn rule of the turn that captured it", () => { @@ -511,6 +513,115 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.queryByRole("button", { name: /2 proof/ })).toBeNull(); }); + it("keeps proof captured after the latest done event visible at the transcript tail", () => { + renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "user_message", text: "Finish first.", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:01:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }, + ], + { proofArtifacts: [{ ...transcriptProofArtifact, createdAt: "2026-03-17T10:02:00.000Z" }] }, + ); + + expect(screen.queryByRole("button", { name: /1 proof/ })).toBeNull(); + expect(screen.getByRole("button", { name: /Proof added/ }).getAttribute("aria-expanded")).toBe("false"); + }); + + it("keeps idle proof before a later turn and never attributes it to that turn", () => { + renderMessageList( + [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "user_message", text: "First turn.", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:01:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:03:00.000Z", + event: { type: "user_message", text: "Later turn.", turnId: "turn-2" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:04:00.000Z", + event: { type: "done", turnId: "turn-2", status: "completed" }, + }, + ], + { proofArtifacts: [{ ...transcriptProofArtifact, createdAt: "2026-03-17T10:02:00.000Z" }] }, + ); + + const proof = screen.getByRole("button", { name: /Proof added/ }); + const laterTurn = screen.getByText("Later turn."); + expect(proof.compareDocumentPosition(laterTurn) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + expect(screen.queryByRole("button", { name: /1 proof/ })).toBeNull(); + }); + + it("does not render trailing proof from outside the loaded history window", () => { + renderMessageList([], { + hasOlderHistory: true, + proofArtifacts: [transcriptProofArtifact], + }); + + expect(screen.queryByRole("button", { name: /Proof added/ })).toBeNull(); + }); + + it("renders broken timeline proof as an amber missing state", () => { + renderMessageList( + [{ + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }], + { + proofArtifacts: [{ + ...transcriptProofArtifact, + availability: "missing_file", + createdAt: "2026-03-17T10:00:00.000Z", + }], + }, + ); + + fireEvent.click(screen.getByRole("button", { name: /1 proof/ })); + expect(screen.getByText("Missing proof")).toBeTruthy(); + expect(document.querySelector('[data-chat-proof-broken="true"]')).toBeTruthy(); + expect(screen.queryByRole("img")).toBeNull(); + }); + + it("resolves proof thumbnails in the non-virtualized transcript path", () => { + renderMessageList( + [{ + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }], + { + allowLocalProofArtifactProtocol: true, + proofArtifacts: [{ + ...transcriptProofArtifact, + kind: "screenshot", + mimeType: "image/png", + uri: ".ade/artifacts/proof.png", + createdAt: "2026-03-17T10:00:00.000Z", + }], + }, + ); + + fireEvent.click(screen.getByRole("button", { name: /1 proof/ })); + expect(screen.getByRole("img", { name: transcriptProofArtifact.title }).getAttribute("src")) + .toBe("ade-artifact://project/.ade/artifacts/proof.png"); + }); + it("keeps turn file-change summaries visible without a session id", () => { renderMessageList([ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index b8e995758..4d7f0e269 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -5217,6 +5217,8 @@ type EventRowProps = { settledQueueRecoveryIds?: Set; /** Proof captured during this turn — surfaced as a chip on the turn rule. */ turnProof?: ComputerUseArtifactView[]; + /** Proof captured after this row but outside a completed turn window. */ + inlineProof?: ComputerUseArtifactView[]; resolveProofThumbnailSrc?: (artifact: ComputerUseArtifactView) => string | null; onOpenProofDrawer?: () => void; }; @@ -5266,6 +5268,7 @@ const EventRow = React.memo(function EventRow({ onRestoreCancelledQueue, settledQueueRecoveryIds, turnProof, + inlineProof, resolveProofThumbnailSrc, onOpenProofDrawer, }: EventRowProps) { @@ -5372,6 +5375,16 @@ const EventRow = React.memo(function EventRow({ sessionId={sessionId} /> ) : null} + {inlineProof?.length ? ( + + ) : null}
); }); @@ -6179,37 +6192,87 @@ function AgentChatMessageListMain({ return `ade-artifact://project/${uri.split("/").map(encodeURIComponent).join("/")}`; }, [allowLocalProofArtifactProtocol]); - const turnProofByRowKey = useMemo(() => { - const map = new Map(); - if (!proofArtifacts.length) return map; + const turnProofTimeline = useMemo(() => { + const byDoneRowKey = new Map(); + const inlineByRowKey = new Map(); + if (!proofArtifacts.length) { + return { byDoneRowKey, inlineByRowKey, unanchored: EMPTY_PROOF_ARTIFACTS }; + } const stamped = proofArtifacts .map((artifact) => ({ artifact, at: Date.parse(artifact.createdAt) })) .filter((entry) => Number.isFinite(entry.at)) .sort((left, right) => left.at - right.at); - if (!stamped.length) return map; + if (!stamped.length) { + return { byDoneRowKey, inlineByRowKey, unanchored: EMPTY_PROOF_ARTIFACTS }; + } const loadedTranscriptStart = allGroupedRows.reduce((earliest, env) => { const at = Date.parse(env.timestamp); return Number.isFinite(at) ? Math.min(earliest, at) : earliest; }, Number.POSITIVE_INFINITY); - if (!Number.isFinite(loadedTranscriptStart)) return map; - let windowStart = loadedTranscriptStart; - let firstWindow = true; + if (!Number.isFinite(loadedTranscriptStart)) { + return { + byDoneRowKey, + inlineByRowKey, + // With no loaded rows, an older page means this is not the transcript + // boundary. Do not pull unknown historic proof into the visible tail. + unanchored: hasOlderHistory ? EMPTY_PROOF_ARTIFACTS : stamped.map((entry) => entry.artifact), + }; + } + const visibleStamped = stamped.filter((entry) => entry.at >= loadedTranscriptStart); + const assignedIds = new Set(); + let turnStartMs: number | null = null; for (const env of allGroupedRows) { + const rowMs = Date.parse(env.timestamp); + if (!Number.isFinite(rowMs)) continue; + if ( + turnStartMs == null + && (getGroupedTurnId(env) != null || env.event.type === "user_message") + ) { + turnStartMs = rowMs; + } if (env.event.type !== "done") continue; - const endMs = Date.parse(env.timestamp); - if (!Number.isFinite(endMs)) continue; - const captured = stamped - .filter((entry) => ( - (firstWindow ? entry.at >= windowStart : entry.at > windowStart) - && entry.at <= endMs - )) + const endMs = rowMs; + const startMs = turnStartMs ?? endMs; + const captured = visibleStamped + .filter((entry) => entry.at >= startMs && entry.at <= endMs) .map((entry) => entry.artifact); - if (captured.length > 0) map.set(env.key, captured); - windowStart = endMs; - firstWindow = false; + if (captured.length > 0) { + byDoneRowKey.set(env.key, captured); + for (const artifact of captured) assignedIds.add(artifact.id); + } + turnStartMs = null; } - return map; - }, [allGroupedRows, proofArtifacts]); + + const visibleRows = groupedRows + .map((row) => ({ row, at: Date.parse(row.timestamp) })) + .filter((entry) => Number.isFinite(entry.at)) + .sort((left, right) => left.at - right.at); + const unanchored: ComputerUseArtifactView[] = []; + for (const entry of visibleStamped) { + if (assignedIds.has(entry.artifact.id)) continue; + let anchorKey: string | null = null; + for (const row of visibleRows) { + if (row.at > entry.at) break; + anchorKey = row.row.key; + } + if (!anchorKey) { + unanchored.push(entry.artifact); + continue; + } + const existing = inlineByRowKey.get(anchorKey) ?? []; + existing.push(entry.artifact); + inlineByRowKey.set(anchorKey, existing); + } + + return { + byDoneRowKey, + inlineByRowKey, + unanchored, + }; + }, [allGroupedRows, groupedRows, hasOlderHistory, proofArtifacts]); + const turnProofByRowKey = turnProofTimeline.byDoneRowKey; + const inlineProofByRowKey = turnProofTimeline.inlineByRowKey; + const unanchoredProofArtifacts = turnProofTimeline.unanchored; const handleReviewChanges = useCallback(() => { if (!turnSummary?.changedFileCount) return; @@ -6944,6 +7007,7 @@ function AgentChatMessageListMain({ const turnProof = envelope.event.type === "done" ? turnProofByRowKey.get(envelope.key) : undefined; + const inlineProof = inlineProofByRowKey.get(envelope.key); const turnModel = currentTurn ? (turnModelState.map.get(currentTurn) ?? null) : turnModelState.lastModel; @@ -6968,6 +7032,7 @@ function AgentChatMessageListMain({ turnEndDurationMs={turnEndDurationMs} turnToolEntries={turnToolEntries} turnProof={turnProof} + inlineProof={inlineProof} resolveProofThumbnailSrc={resolveProofThumbnailSrc} onOpenProofDrawer={onOpenProofDrawer} onApproval={handleApproval} @@ -7021,6 +7086,8 @@ function AgentChatMessageListMain({ turnEndDurationMs={turnEndDurationMs} turnToolEntries={turnToolEntries} turnProof={turnProof} + inlineProof={inlineProof} + resolveProofThumbnailSrc={resolveProofThumbnailSrc} onOpenProofDrawer={onOpenProofDrawer} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} @@ -7060,7 +7127,7 @@ function AgentChatMessageListMain({ settledQueueRecoveryIds={settledQueueRecoveryIds} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey, turnProofByRowKey, resolveProofThumbnailSrc, onOpenProofDrawer]); + }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRecoverContinuity, onRetryProviderFailure, onChooseProviderFailureModel, onRunUnprocessedMessage, onEditUnprocessedMessage, onDismissUnprocessedMessage, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, settledQueueRecoveryIds, onCancelQueuedMessage, onRestoreCancelledQueue, transcriptToolActivity, turnEndDurationByRowKey, turnProofByRowKey, inlineProofByRowKey, resolveProofThumbnailSrc, onOpenProofDrawer]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { @@ -7102,6 +7169,18 @@ function AgentChatMessageListMain({ // End-of-turn dividers now render inline at each `done` row (DoneTurnDivider), // so there is no separate bottom divider. const turnDivider = null; + const trailingProof = unanchoredProofArtifacts.length > 0 ? ( +
+ +
+ ) : null; // Jump-to-latest pill is only meaningful during an active turn — if nothing // is streaming there's no "latest" to catch up to. @@ -7172,10 +7251,9 @@ function AgentChatMessageListMain({ ) : null}
) : null} - {/* Proof is no longer a thread footer: it renders inline at the point - of capture as an `ade_card` row, so an empty transcript is empty - even when the chat owns artifacts. */} - {rows.length === 0 && !streamingIndicator ? ( + {/* Proof with no following turn completion is a chronological tail + row, not the old permanently pinned footer. */} + {rows.length === 0 && !streamingIndicator && !trailingProof ? ( null ) : shouldVirtualize ? ( /* ── Virtualized path: only render rows in / near the viewport ── */ @@ -7191,6 +7269,7 @@ function AgentChatMessageListMain({ {/* Bottom spacer fills remaining scroll area */}
+ {trailingProof} {streamingIndicator} {turnDivider}
@@ -7198,6 +7277,7 @@ function AgentChatMessageListMain({ /* ── Non-virtualized path: render all rows (small conversation) ── */
{groupedRows.map((envelope, index) => renderRow(envelope, index, false))} + {trailingProof} {streamingIndicator} {turnDivider}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 23b5caebb..e2ee847e6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -14,6 +14,9 @@ import type { AgentChatSessionSummary, AgentChatSteerResult, AiSettingsStatus, + ComputerUseArtifactView, + ComputerUseEventPayload, + ComputerUseOwnerSnapshot, PrSummary, TerminalSessionChangedEvent, TerminalSessionDetail, @@ -585,6 +588,7 @@ function installAdeMocks(options?: { const writeClipboardText = vi.fn().mockResolvedValue(undefined); const chatEventListeners = new Set<(event: AgentChatEventEnvelope) => void>(); const sessionChangeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); + const computerUseEventListeners = new Set<(event: ComputerUseEventPayload) => void>(); globalThis.window.ade = { app: { @@ -714,7 +718,13 @@ function installAdeMocks(options?: { }, computerUse: { getOwnerSnapshot: vi.fn().mockResolvedValue({ artifacts: [] }), - onEvent: vi.fn().mockImplementation(() => () => undefined), + readArtifactPreview: vi.fn().mockResolvedValue(null), + onEvent: vi.fn().mockImplementation((listener: (event: ComputerUseEventPayload) => void) => { + computerUseEventListeners.add(listener); + return () => { + computerUseEventListeners.delete(listener); + }; + }), }, files: { listWorkspaces: vi.fn().mockResolvedValue([]), @@ -814,6 +824,11 @@ function installAdeMocks(options?: { listener(event); } }, + emitComputerUseEvent: (event: ComputerUseEventPayload) => { + for (const listener of computerUseEventListeners) { + listener(event); + } + }, }; } @@ -1398,6 +1413,76 @@ describe("AgentChatPane companion drawers", () => { expect(screen.queryByRole("button", { name: "Close chat actions drawer" })).toBeNull(); }); + it("removes deleted proof from the open drawer when the event no longer has an owner", async () => { + const session = buildSession("session-1", { title: "Proof event chat" }); + const proof: ComputerUseArtifactView = { + id: "proof-deleted", + kind: "screenshot", + backendStyle: "local_fallback", + backendName: "ADE", + sourceToolName: "capture", + originalType: "image", + title: "Proof to delete", + description: null, + uri: ".ade/artifacts/proof-deleted.png", + storageKind: "file", + mimeType: "image/png", + metadata: {}, + createdAt: "2026-07-28T12:00:00.000Z", + links: [], + reviewState: "pending", + workflowState: "evidence_only", + reviewNote: null, + availability: "available", + }; + const mocks = installAdeMocks({ sessions: [session] }); + const populatedSnapshot: ComputerUseOwnerSnapshot = { + owner: { kind: "chat_session", id: session.sessionId }, + backendStatus: { + backends: [], + localFallback: { available: true, detail: "Available", supportedKinds: ["screenshot"] }, + }, + summary: "1 proof item", + activeBackend: null, + artifacts: [proof], + recentArtifacts: [proof], + activity: [], + }; + vi.mocked(window.ade.computerUse.getOwnerSnapshot) + .mockResolvedValueOnce(populatedSnapshot) + .mockResolvedValue({ + owner: { kind: "chat_session", id: session.sessionId }, + backendStatus: { + backends: [], + localFallback: { available: true, detail: "Available", supportedKinds: ["screenshot"] }, + }, + summary: "No proof", + activeBackend: null, + artifacts: [], + recentArtifacts: [], + activity: [], + }); + seedDrawerStore(); + renderPane(session); + + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Proof" })); + expect(await screen.findByText("Proof to delete")).toBeTruthy(); + + act(() => { + mocks.emitComputerUseEvent({ + type: "artifact-deleted", + artifactId: proof.id, + at: "2026-07-28T12:01:00.000Z", + owner: null, + }); + }); + + await waitFor(() => expect(screen.queryByText("Proof to delete")).toBeNull()); + expect(await screen.findByText("No proof collected yet")).toBeTruthy(); + expect(window.ade.computerUse.getOwnerSnapshot).toHaveBeenCalledTimes(2); + }); + it("does not reopen chat actions after tasks arrive while the Agents tab is already open", async () => { const session = buildSession("session-1"); const { emitChatEvent } = installAdeMocks({ sessions: [session] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 6459b7c97..ff2a6a686 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -7393,7 +7393,21 @@ export function AgentChatPane({ if (!isTileActive) return undefined; const unsubscribe = window.ade.computerUse.onEvent((event) => { if (!selectedSessionId) return; - if (event.owner?.kind === "chat_session" && event.owner.id === selectedSessionId) { + const belongsToSelectedChat = event.owner?.kind === "chat_session" + && event.owner.id === selectedSessionId; + // Global/CLI deletes can arrive after their owner links are gone, so the + // event may not carry an owner. Optimistically remove the matching id + // from this drawer, then force an authoritative snapshot refresh. + if (event.type === "artifact-deleted" && (event.owner == null || belongsToSelectedChat)) { + setComputerUseSnapshot((current) => current + ? { + ...current, + artifacts: current.artifacts.filter((artifact) => artifact.id !== event.artifactId), + recentArtifacts: current.recentArtifacts.filter((artifact) => artifact.id !== event.artifactId), + } + : current); + void refreshComputerUseSnapshot(selectedSessionId, { force: true }); + } else if (belongsToSelectedChat) { void refreshComputerUseSnapshot(selectedSessionId, { force: true }); } }); diff --git a/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx index 041e65cd7..8e0eadcb4 100644 --- a/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx +++ b/apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx @@ -6,6 +6,7 @@ import { Cube, Prohibit, SpinnerGap, + WarningCircle, XCircle, type Icon as PhosphorIcon, } from "@phosphor-icons/react"; @@ -419,17 +420,34 @@ export function ChatProofFilmstrip({ {open ? (
{artifacts.map((artifact) => { - const src = resolveThumbnailSrc?.(artifact) ?? null; + const broken = artifact.availability != null && artifact.availability !== "available"; + const src = broken ? null : (resolveThumbnailSrc?.(artifact) ?? null); return (
diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 6849c56e2..925173df0 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -4119,22 +4119,11 @@ struct WorkAdeCardView: View { /// Cap the detail list: the transcript row is a summary, and the full list /// lives behind the card's nav target. private var visibleRows: [WorkAdeCardRow] { - let candidates: [WorkAdeCardRow] - if card.variant == "pr_ci" { - // Mobile's selected checks treatment is failures-only. A green run stays - // one line; failed checks earn the small amount of transcript space. - candidates = card.rows.filter { $0.icon == .fail || $0.tone == .warning } - } else { - candidates = card.rows - } - return Array(candidates.prefix(4)) + workAdeCardVisibleRows(card) } private var hiddenRowCount: Int { - if card.variant == "pr_ci" { - return card.rowsTruncated ?? 0 - } - return (card.rowsTruncated ?? 0) + max(0, card.rows.count - visibleRows.count) + workAdeCardHiddenRowCount(card) } private var detailRows: some View { @@ -4238,6 +4227,22 @@ struct WorkAdeCardView: View { } } +func workAdeCardVisibleRows(_ card: WorkAdeCardModel) -> [WorkAdeCardRow] { + Array(workAdeCardCandidateRows(card).prefix(4)) +} + +func workAdeCardHiddenRowCount(_ card: WorkAdeCardModel) -> Int { + let candidates = workAdeCardCandidateRows(card) + return (card.rowsTruncated ?? 0) + max(0, candidates.count - workAdeCardVisibleRows(card).count) +} + +private func workAdeCardCandidateRows(_ card: WorkAdeCardModel) -> [WorkAdeCardRow] { + guard card.variant == "pr_ci" else { return card.rows } + // Mobile's selected checks treatment is failures-only. A green run stays + // one line; failed checks earn the small amount of transcript space. + return card.rows.filter { $0.icon == .fail || $0.tone == .warning } +} + func workAdeCardToneColor(_ tone: WorkAdeCardTone) -> Color { switch tone { case .neutral: return ADEColor.textSecondary diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index 0a431aa15..fb988412b 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -766,7 +766,7 @@ func makeWorkAdeCardModel(from payload: AgentChatAdeCardPayload) -> WorkAdeCardM navTarget: makeWorkAdeCardNavTarget(from: payload.navTarget), actions: actions, durationMs: payload.durationMs, - degradedReason: payload.degradedReason, + degradedReason: optionalString(payload.degradedReason), isStale: payload.stale, rowsTruncated: payload.rowsTruncated.map { max(0, $0) }, fallbackText: fallbackText, diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 3154636d2..28dc0b6f8 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -7480,6 +7480,53 @@ final class ADETests: XCTestCase { XCTAssertNil(recovered.degradedReason) XCTAssertEqual(recovered.isStale, false) XCTAssertEqual(recovered.rowsTruncated, 0) + + let blankReasonPayload = try JSONDecoder().decode( + AgentChatAdeCardPayload.self, + from: Data(""" + { + "cardId": "ci-927", + "variant": "pr_ci", + "state": "terminal", + "title": "Checks", + "fallbackText": "Checks passed", + "degradedReason": " \\n " + } + """.utf8) + ) + XCTAssertNil(makeWorkAdeCardModel(from: blankReasonPayload).degradedReason) + } + + func testAdeCardFailureSummaryCountsOnlyLocallyOmittedFailures() throws { + let payload = try JSONDecoder().decode( + AgentChatAdeCardPayload.self, + from: Data(""" + { + "cardId": "ci-927", + "variant": "pr_ci", + "state": "terminal", + "title": "Checks", + "fallbackText": "Checks failed", + "rows": [ + {"icon": "fail", "text": "failure-1"}, + {"icon": "fail", "text": "failure-2"}, + {"icon": "fail", "text": "failure-3"}, + {"icon": "fail", "text": "failure-4"}, + {"icon": "fail", "text": "failure-5"}, + {"icon": "fail", "text": "failure-6"}, + {"icon": "pass", "text": "passing-1"}, + {"icon": "pass", "text": "passing-2"} + ], + "rowsTruncated": 3 + } + """.utf8) + ) + let card = makeWorkAdeCardModel(from: payload) + + XCTAssertEqual(workAdeCardVisibleRows(card).map(\.text), [ + "failure-1", "failure-2", "failure-3", "failure-4", + ]) + XCTAssertEqual(workAdeCardHiddenRowCount(card), 5) } func testAdeCardPreservesKnownProgressAcrossDegradedZeroUpdate() throws { From a5c01b413c027f0fad4ff6c6142711f67b5acd3b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:22:35 -0400 Subject: [PATCH 13/20] Fix proof ingestion and scoped ordering --- apps/ade-cli/src/adeRpcServer.test.ts | 48 +++++++++++++++++++++++++++ apps/ade-cli/src/adeRpcServer.ts | 26 ++++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index d07b35df8..0b3bfe94d 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1360,6 +1360,28 @@ describe("adeRpcServer", () => { }); }); + it("sorts the scoped proof union before applying its limit", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-1", laneId: "lane-1" } as any); + const older = { id: "older", createdAt: "2026-07-29T01:00:00.000Z" }; + const newer = { id: "newer", createdAt: "2026-07-29T02:00:00.000Z" }; + fixture.runtime.computerUseArtifactBrokerService.listArtifacts.mockImplementation((args: any) => { + if (args.ownerKind === "chat_session") return [older]; + if (args.ownerKind === "lane") return [newer]; + return []; + }); + await initialize(handler, { + callerId: "chat-1", + role: "agent", + chatSessionId: "chat-1", + }); + + const listed = await callTool(handler, "list_computer_use_artifacts", { limit: 1 }); + + expect(listed.structuredContent.artifacts).toEqual([newer]); + }); + it("caps a session-bound CTO caller and scopes lifecycle actions to its own session", async () => { await withEnv({ ADE_DEFAULT_ROLE: "cto", ADE_CHAT_SESSION_ID: undefined }, async () => { const { runtime } = createRuntime(); @@ -1952,6 +1974,32 @@ describe("adeRpcServer", () => { expect(fixture.runtime.computerUseArtifactBrokerService.ingest).not.toHaveBeenCalled(); }); + it("allows absolute proof paths outside managed worktrees to reach the broker allow-list", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + fs.mkdirSync(fixture.runtime.laneService.getLaneWorktreePath("lane-1"), { recursive: true }); + fixture.runtime.sessionService.get.mockReturnValue({ id: "chat-session-1", laneId: "lane-1" } as any); + await initialize(handler, { + callerId: "chat-session-1", + role: "agent", + chatSessionId: "chat-session-1", + }); + + const externalPath = path.join(os.tmpdir(), "ade-proof-external.png"); + const response = await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "external_cli", + backendName: "agent-browser", + inputs: [{ kind: "screenshot", title: "External proof", path: externalPath }], + }); + + expect(response.isError).toBeUndefined(); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: [expect.objectContaining({ path: externalPath })], + }), + ); + }); + it("rejects standalone chat calls to ADE spawn_agent", async () => { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 98a291a77..025393d16 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2102,6 +2102,15 @@ function canonicalAuthorizationPath(candidate: string): string { } } +function isPathWithinAuthorizedRoot(root: string, candidate: string): boolean { + try { + resolvePathWithinRoot(root, candidate, { allowMissing: true }); + return true; + } catch { + return false; + } +} + function resolveAuthorizedComputerUseIngestRoot( runtime: AdeRuntime, session: SessionState, @@ -4519,12 +4528,15 @@ async function runTool(args: { return uri && !/^https?:\/\//i.test(uri) ? uri : null; })(); if (!localPath || !path.isAbsolute(localPath)) continue; - try { - resolvePathWithinRoot(authorized.root, localPath, { allowMissing: true }); - } catch { + if (isPathWithinAuthorizedRoot(authorized.root, localPath)) continue; + // Absolute proof paths may also come from broker-approved external + // roots such as the OS temp directory or ~/.agent-browser. Preserve the + // lane boundary here, then let the broker enforce its full jailed + // allow-list and extension policy. + if (isPathWithinAuthorizedRoot(runtime.paths.worktreesDir, localPath)) { throw new JsonRpcError( JsonRpcErrorCode.invalidParams, - "Artifact paths must stay inside the server-authorized lane worktree", + "Artifact paths from another lane worktree are not authorized for this caller", ); } } @@ -4585,7 +4597,11 @@ async function runTool(args: { artifacts.set(artifact.id, artifact); } } - return { artifacts: [...artifacts.values()].slice(0, limit) }; + return { + artifacts: [...artifacts.values()] + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .slice(0, limit), + }; } return { artifacts: runtime.computerUseArtifactBrokerService.listArtifacts({ From 8f17794c6e75b60f53fc09eaf933314769f122ea Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:27:32 -0400 Subject: [PATCH 14/20] Address proof review follow-ups --- apps/ade-cli/src/adeRpcServer.test.ts | 30 ++++++++++++++++-- apps/ade-cli/src/adeRpcServer.ts | 31 ++++++++++++++----- .../computerUseArtifactBrokerService.ts | 5 ++- .../settings/StorageSection.test.tsx | 9 +++--- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 0b3bfe94d..622ffaf02 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1313,9 +1313,11 @@ describe("adeRpcServer", () => { failed: [], freedBytes: 0, })); - fixture.runtime.computerUseArtifactBrokerService.listBrokenArtifacts = vi.fn(() => []); + fixture.runtime.computerUseArtifactBrokerService.listBrokenArtifacts = vi.fn(() => [ + { artifactId: owned.id }, + { artifactId: foreign.id }, + ]); fixture.runtime.computerUseArtifactBrokerService.recoverArtifact = vi.fn(); - fixture.runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts = vi.fn(); await initialize(handler, { callerId: "chat-1", role: "agent", @@ -1358,6 +1360,30 @@ describe("adeRpcServer", () => { expect(fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts).toHaveBeenCalledWith({ artifactIds: [owned.id], }); + + fixture.runtime.computerUseArtifactBrokerService.listArtifacts.mockClear(); + const broken = await callTool(handler, "list_broken_computer_use_artifacts", { limit: 10 }); + expect(broken.structuredContent.broken).toEqual([{ artifactId: owned.id }]); + expect(fixture.runtime.computerUseArtifactBrokerService.listArtifacts).toHaveBeenCalledTimes(2); + + fixture.runtime.computerUseArtifactBrokerService.listArtifacts.mockClear(); + fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts.mockClear(); + await callTool(handler, "prune_broken_computer_use_artifacts", {}); + expect(fixture.runtime.computerUseArtifactBrokerService.listArtifacts).toHaveBeenCalledTimes(2); + expect(fixture.runtime.computerUseArtifactBrokerService.deleteArtifacts).toHaveBeenCalledWith({ + artifactIds: [owned.id], + }); + + const foreignRecover = await callTool(handler, "recover_computer_use_artifact", { + artifactId: foreign.id, + }); + expect(foreignRecover.isError).toBe(true); + expect(fixture.runtime.computerUseArtifactBrokerService.recoverArtifact).not.toHaveBeenCalled(); + + await callTool(handler, "recover_computer_use_artifact", { artifactId: owned.id }); + expect(fixture.runtime.computerUseArtifactBrokerService.recoverArtifact).toHaveBeenCalledWith({ + artifactId: owned.id, + }); }); it("sorts the scoped proof union before applying its limit", async () => { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 025393d16..2d7e58e0e 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2219,6 +2219,23 @@ function artifactMatchesAuthorizedOwners( ); } +function listAuthorizedProofArtifactIds( + runtime: AdeRuntime, + owners: ComputerUseArtifactOwner[], +): Set { + const artifactIds = new Set(); + for (const owner of owners) { + for (const artifact of runtime.computerUseArtifactBrokerService.listArtifacts({ + ownerKind: owner.kind, + ownerId: owner.id, + limit: 2000, + })) { + artifactIds.add(artifact.id); + } + } + return artifactIds; +} + function branchNameForPrTitle(ref: string | null | undefined): string { let value = (ref ?? "").trim(); value = value.replace(/^refs\/heads\//, ""); @@ -4648,11 +4665,11 @@ async function runTool(args: { if (!authorizedOwners.length) { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Broken-proof listing requires an authenticated owner scope."); } + const authorizedArtifactIds = listAuthorizedProofArtifactIds(runtime, authorizedOwners); return { - broken: broken.filter((entry) => { - const artifact = runtime.computerUseArtifactBrokerService.listArtifacts({ artifactId: entry.artifactId })[0] ?? null; - return artifactMatchesAuthorizedOwners(artifact, authorizedOwners); - }).slice(0, requestedLimit), + broken: broken + .filter((entry) => authorizedArtifactIds.has(entry.artifactId)) + .slice(0, requestedLimit), }; } return { @@ -4668,11 +4685,9 @@ async function runTool(args: { if (!authorizedOwners.length) { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, "Broken-proof pruning requires an authenticated owner scope."); } + const authorizedArtifactIds = listAuthorizedProofArtifactIds(runtime, authorizedOwners); const artifactIds = runtime.computerUseArtifactBrokerService.listBrokenArtifacts({ limit: 2000 }) - .filter((entry) => { - const artifact = runtime.computerUseArtifactBrokerService.listArtifacts({ artifactId: entry.artifactId })[0] ?? null; - return artifactMatchesAuthorizedOwners(artifact, authorizedOwners); - }) + .filter((entry) => authorizedArtifactIds.has(entry.artifactId)) .map((entry) => entry.artifactId); return artifactIds.length ? runtime.computerUseArtifactBrokerService.deleteArtifacts({ artifactIds }) diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 53e746680..73acb3e1d 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -1075,7 +1075,10 @@ export function createComputerUseArtifactBrokerService(args: { }, listArtifacts(args: ComputerUseArtifactListArgs = {}): ComputerUseArtifactView[] { - const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 50))); + // Public callers cap ordinary list responses at 200. Internal proof + // maintenance may request up to the broken-record audit ceiling so it + // can authorize a bounded batch without one lookup per artifact. + const limit = Math.max(1, Math.min(2000, Math.floor(args.limit ?? 50))); let artifacts: ComputerUseArtifactRecord[] = []; const artifactId = toOptionalString(args.artifactId); if (artifactId) { diff --git a/apps/desktop/src/renderer/components/settings/StorageSection.test.tsx b/apps/desktop/src/renderer/components/settings/StorageSection.test.tsx index e20a24ea0..009ed4819 100644 --- a/apps/desktop/src/renderer/components/settings/StorageSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/StorageSection.test.tsx @@ -370,15 +370,16 @@ describe("StorageSection", () => { expect(screen.getByText(/This is your project's live data/)).toBeTruthy(); }); - it("preserves ADE casing in proof cleanup and directs blocked cleanup to the proof drawer", async () => { + it("preserves ADE casing in proof cleanup", async () => { installAdeMock(); render(); - const proofRow = (await screen.findByText("Proof and recordings")).parentElement!.parentElement!.parentElement!; - fireEvent.click(within(proofRow).getByRole("button", { name: "Remove…" })); + const proofCard = (await screen.findByRole("heading", { name: "Proof & attachments" })).closest("section")!; + fireEvent.click(within(proofCard).getByRole("button", { name: "Remove…" })); expect(await screen.findByRole("dialog", { name: "Remove Proof and recordings" })).toBeTruthy(); - cleanup(); + }); + it("directs blocked proof cleanup to the proof drawer", async () => { installAdeMock(); const blockedSnapshot = makeSnapshot(); const proof = blockedSnapshot.categories.find((category) => category.id === "proof_attachments")!; From c21168c50af5c80710e1506d7c46023b2c042801 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:43:24 -0400 Subject: [PATCH 15/20] Fix proof preview and deletion feedback --- .../chat/ChatComputerUsePanel.test.tsx | 49 +++++++++++++++++++ .../components/chat/ChatComputerUsePanel.tsx | 44 +++++++++++------ 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx index 12147d187..3d2a1ce27 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx @@ -127,6 +127,24 @@ describe("proof rendering", () => { }); }); + it("distinguishes an unavailable preview from a deleted stored file", async () => { + vi.mocked(window.ade.computerUse.readArtifactPreview).mockResolvedValueOnce(null); + + render(); + + expect(await screen.findByText(/preview is unavailable, but the stored proof is still attached/i)).toBeTruthy(); + expect(screen.queryByText(/stored file has since been deleted/i)).toBeNull(); + }); + + it("reports a missing stored file as deleted", async () => { + render(); + + expect(await screen.findByText(/stored file has since been deleted/i)).toBeTruthy(); + }); + it("keeps the latest six proof items inline and links earlier proof to the drawer", async () => { const onOpenDrawer = vi.fn(); render( @@ -189,6 +207,37 @@ describe("proof rendering", () => { expect(await screen.findByText("Artifact is locked")).toBeTruthy(); }); + it("surfaces resolved delete failures and does not refresh unchanged proof", async () => { + const onRefresh = vi.fn(); + (window.ade as any).computerUse.deleteArtifacts = vi.fn().mockResolvedValue({ + deleted: [], + missing: [], + failed: [{ artifactId: "artifact-1", reason: "Permission denied" }], + freedBytes: 0, + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete Proof 1" })); + + expect(await screen.findByText("Permission denied")).toBeTruthy(); + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it("surfaces resolved bulk-prune failures", async () => { + const broken = artifact(7, { availability: "missing_file" }); + (window.ade as any).computerUse.deleteArtifacts = vi.fn().mockResolvedValue({ + deleted: [], + missing: [], + failed: [{ artifactId: broken.id, reason: "File is locked" }], + freedBytes: 0, + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Remove it" })); + + expect(await screen.findByText("File is locked")).toBeTruthy(); + }); + it("uses a remote-safe external action only for HTTP artifacts", async () => { const remote = artifact(3, { uri: "https://proof.example/capture.png", diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx index ffbff3f40..f4ca72594 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx @@ -18,7 +18,11 @@ import React, { useState, } from "react"; import { createPortal } from "react-dom"; -import type { ComputerUseArtifactView, ComputerUseOwnerSnapshot } from "../../../shared/types"; +import type { + ComputerUseArtifactDeleteResult, + ComputerUseArtifactView, + ComputerUseOwnerSnapshot, +} from "../../../shared/types"; import { cn } from "../ui/cn"; function isImageArtifact(artifact: ComputerUseArtifactView): boolean { @@ -72,17 +76,26 @@ function shortSourcePath(artifact: ComputerUseArtifactView): string | null { } /** - * We know exactly why a tile is blank, so say it. "Preview unavailable" told - * the user nothing and offered nothing to do about it. + * Keep canonical storage availability separate from preview generation. + * A missing preview can mean unsupported media or a size cap even when the + * stored proof is intact. */ -function brokenArtifactExplanation(artifact: ComputerUseArtifactView): string { +function artifactPreviewExplanation(artifact: ComputerUseArtifactView): string { const where = shortSourcePath(artifact); if (artifactAvailability(artifact) === "unimported") { return where ? `Never copied into ADE's storage — it was left at ${where} in the lane it was captured in.` : "Never copied into ADE's storage, so there are no bytes to show."; } - return "The stored file has since been deleted."; + if (artifactAvailability(artifact) === "missing_file") { + return "The stored file has since been deleted."; + } + return "A preview is unavailable, but the stored proof is still attached."; +} + +function assertArtifactDeletionSucceeded(result: ComputerUseArtifactDeleteResult): void { + if (result.failed.length === 0) return; + throw new Error(result.failed.map((failure) => failure.reason).join("; ")); } function localArtifactUrl(uri: string): string | null { @@ -159,15 +172,14 @@ function useVisibleArtifactPreview( .then((dataUrl) => { if (cancelled) return; setPreview(dataUrl); + setLoading(false); setLoaded(true); }) .catch(() => { if (cancelled) return; setPreview(null); + setLoading(false); setLoaded(true); - }) - .finally(() => { - if (!cancelled) setLoading(false); }); return () => { cancelled = true; @@ -314,7 +326,7 @@ export function ChatProofArtifactCard({
{externalUrl ? "This proof lives at its source. Open it to view." - : brokenArtifactExplanation(artifact)} + : artifactPreviewExplanation(artifact)}
) : preview && image ? ( @@ -556,7 +568,7 @@ function DrawerProofTile({
{broken ? (
- {externalUrl ? "Stored at its source." : brokenArtifactExplanation(artifact)} + {externalUrl ? "Stored at its source." : artifactPreviewExplanation(artifact)}
) : null}
@@ -614,9 +626,10 @@ export function ChatComputerUsePanel({ const handleDelete = useCallback( (artifact: ComputerUseArtifactView) => { - void withBusy([artifact.id], () => - window.ade.computerUse.deleteArtifacts({ artifactId: artifact.id }), - ); + void withBusy([artifact.id], async () => { + const result = await window.ade.computerUse.deleteArtifacts({ artifactId: artifact.id }); + assertArtifactDeletionSucceeded(result); + }); }, [withBusy], ); @@ -633,7 +646,10 @@ export function ChatComputerUsePanel({ const handlePruneBroken = useCallback(() => { const ids = artifacts.filter((artifact) => isBrokenArtifact(artifact)).map((artifact) => artifact.id); if (!ids.length) return; - void withBusy(ids, () => window.ade.computerUse.deleteArtifacts({ artifactIds: ids })); + void withBusy(ids, async () => { + const result = await window.ade.computerUse.deleteArtifacts({ artifactIds: ids }); + assertArtifactDeletionSucceeded(result); + }); }, [artifacts, withBusy]); if (!snapshot || artifacts.length === 0) { From ca6a80326d1a50d7dbfc76616ff725a35ea349ce Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:58:22 -0400 Subject: [PATCH 16/20] Show proof preview failures in drawer --- .../chat/ChatComputerUsePanel.test.tsx | 5 ++++- .../components/chat/ChatComputerUsePanel.tsx | 18 +++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx index 3d2a1ce27..5d8497396 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx @@ -130,7 +130,10 @@ describe("proof rendering", () => { it("distinguishes an unavailable preview from a deleted stored file", async () => { vi.mocked(window.ade.computerUse.readArtifactPreview).mockResolvedValueOnce(null); - render(); + render(); expect(await screen.findByText(/preview is unavailable, but the stored proof is still attached/i)).toBeTruthy(); expect(screen.queryByText(/stored file has since been deleted/i)).toBeNull(); diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx index f4ca72594..01ea26e52 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx @@ -457,16 +457,19 @@ function DrawerProofTile({ onDelete: (artifact: ComputerUseArtifactView) => void; onRecover: (artifact: ComputerUseArtifactView) => void; }) { - const { containerRef, preview, loading } = useVisibleArtifactPreview( + const { containerRef, preview, loading, loaded } = useVisibleArtifactPreview( artifact, allowLocalArtifactProtocol, ); const [lightboxOpen, setLightboxOpen] = useState(false); const [mediaFailed, setMediaFailed] = useState(false); - const broken = isBrokenArtifact(artifact) || mediaFailed; const image = isImageArtifact(artifact); const video = isVideoArtifact(artifact); const externalUrl = externalArtifactUrl(artifact.uri); + const storedFileMissing = isBrokenArtifact(artifact); + const previewUnavailable = !storedFileMissing + && (mediaFailed || (loaded && !preview && (image || video))); + const hasPreviewProblem = storedFileMissing || previewUnavailable; const recoverable = typeof artifact.metadata?.sourcePath === "string"; useEffect(() => { @@ -485,13 +488,14 @@ function DrawerProofTile({
- {broken ? ( - // Broken items shrink to a short strip instead of an empty box. + {hasPreviewProblem ? ( + // Missing or unpreviewable items shrink to a short strip instead of + // wasting the drawer rail on an empty thumbnail.
@@ -538,7 +542,7 @@ function DrawerProofTile({ ) : null} - {broken && recoverable ? ( + {storedFileMissing && recoverable ? (
- {broken ? ( + {hasPreviewProblem ? (
{externalUrl ? "Stored at its source." : artifactPreviewExplanation(artifact)}
From 1da24f81766804748a4af9f006d8fa922e14210b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:22:56 -0400 Subject: [PATCH 17/20] Fix proof attachment lane authorization --- apps/ade-cli/src/adeRpcServer.test.ts | 30 +++++ apps/ade-cli/src/adeRpcServer.ts | 52 ++++++--- .../computerUseArtifactBrokerService.test.ts | 105 ++++++++++++++++++ .../computerUseArtifactBrokerService.ts | 46 +++++--- 4 files changed, 201 insertions(+), 32 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 622ffaf02..62463d7e3 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1941,6 +1941,36 @@ describe("adeRpcServer", () => { ); }); + it("infers the lane scope for standalone ade proof attach calls", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const laneRoot = fixture.runtime.laneService.getLaneWorktreePath("lane-1"); + const callerRoot = path.join(laneRoot, "packages", "app"); + fs.mkdirSync(callerRoot, { recursive: true }); + + await initialize(handler, { + callerId: "ade-cli:4242", + role: "agent", + }); + const response = await callTool(handler, "ingest_computer_use_artifacts", { + backendStyle: "manual", + backendName: "ade-cli", + toolName: "proof attach", + callerRoot, + inputs: [{ kind: "screenshot", title: "Standalone proof", path: path.join(callerRoot, "proof.png") }], + }); + + expect(response.isError).toBeUndefined(); + expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( + expect.objectContaining({ + callerRoot: fs.realpathSync(callerRoot), + owners: expect.arrayContaining([ + expect.objectContaining({ kind: "lane", id: "lane-1" }), + ]), + }), + ); + }); + it("rejects a relative caller root, which would resolve differently on each side", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 2d7e58e0e..e4f6a675b 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2111,23 +2111,43 @@ function isPathWithinAuthorizedRoot(root: string, candidate: string): boolean { } } -function resolveAuthorizedComputerUseIngestRoot( +async function resolveAuthorizedComputerUseIngestRoot( runtime: AdeRuntime, session: SessionState, toolArgs: Record, -): { laneId: string | null; root: string } { +): Promise<{ laneId: string | null; root: string; callerRoot: string }> { const requestedLaneId = asOptionalTrimmedString(toolArgs.laneId); const sessionLaneId = resolveChatSessionLaneId(runtime, session); const projectWideAuthorized = session.identity.role === "cto" && isUserClientSession(session); + const callerRoot = asOptionalTrimmedString(toolArgs.callerRoot); + if (callerRoot && !path.isAbsolute(callerRoot)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "callerRoot must be an absolute path"); + } if (!projectWideAuthorized && requestedLaneId && requestedLaneId !== sessionLaneId) { throw new JsonRpcError( JsonRpcErrorCode.invalidParams, "laneId must match the caller's authorized chat-session lane", ); } - const authorizedLaneId = requestedLaneId ?? sessionLaneId; + const inferredLane = !requestedLaneId + && !sessionLaneId + && callerRoot + && isUnboundAdeCliCaller(session) + ? (await runtime.laneService.list({ includeArchived: false, includeStatus: false }).catch(() => [])) + .flatMap((lane) => { + const roots = [lane.worktreePath, lane.attachedRootPath] + .map((root) => asOptionalTrimmedString(root)) + .filter((root): root is string => Boolean(root)) + .map((root) => canonicalAuthorizationPath(root)); + return roots + .filter((root) => isPathWithinAuthorizedRoot(root, callerRoot)) + .map((root) => ({ laneId: lane.id, root })); + }) + .sort((left, right) => right.root.length - left.root.length)[0] ?? null + : null; + const authorizedLaneId = requestedLaneId ?? sessionLaneId ?? inferredLane?.laneId ?? null; const authorizedRoot = authorizedLaneId - ? resolveLaneWorktreePath(runtime, authorizedLaneId) + ? inferredLane?.root ?? resolveLaneWorktreePath(runtime, authorizedLaneId) : projectWideAuthorized ? runtime.projectRoot : null; @@ -2137,20 +2157,21 @@ function resolveAuthorizedComputerUseIngestRoot( "Computer-use ingestion requires an authorized lane worktree.", ); } - const callerRoot = asOptionalTrimmedString(toolArgs.callerRoot); - if (callerRoot && !path.isAbsolute(callerRoot)) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "callerRoot must be an absolute path"); - } if ( callerRoot - && canonicalAuthorizationPath(callerRoot) !== canonicalAuthorizationPath(authorizedRoot) + && !isPathWithinAuthorizedRoot(authorizedRoot, callerRoot) ) { throw new JsonRpcError( JsonRpcErrorCode.invalidParams, - "callerRoot must match the server-authorized lane worktree", + "callerRoot must be inside the server-authorized lane worktree", ); } - return { laneId: authorizedLaneId, root: canonicalAuthorizationPath(authorizedRoot) }; + const canonicalRoot = canonicalAuthorizationPath(authorizedRoot); + return { + laneId: authorizedLaneId, + root: canonicalRoot, + callerRoot: canonicalAuthorizationPath(callerRoot ?? canonicalRoot), + }; } function isProjectWideProofMaintenanceAuthorized(session: SessionState): boolean { @@ -4536,7 +4557,7 @@ async function runTool(args: { if (inputs.length === 0) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "Provide inputs for computer-use ingestion."); } - const authorized = resolveAuthorizedComputerUseIngestRoot(runtime, session, toolArgs); + const authorized = await resolveAuthorizedComputerUseIngestRoot(runtime, session, toolArgs); validateComputerUseOwnerClaims(runtime, session, toolArgs); for (const input of inputs) { const localPath = asOptionalTrimmedString(input.path) @@ -4564,7 +4585,7 @@ async function runTool(args: { toolName: asOptionalTrimmedString(toolArgs.toolName), command: asOptionalTrimmedString(toolArgs.command), }, - callerRoot: authorized.root, + callerRoot: authorized.callerRoot, inputs: inputs.map((entry) => ({ kind: asOptionalTrimmedString(entry.kind), title: asOptionalTrimmedString(entry.title), @@ -4577,7 +4598,10 @@ async function runTool(args: { rawType: asOptionalTrimmedString(entry.rawType), ...(isRecord(entry.metadata) ? { metadata: entry.metadata } : {}), })), - owners: resolveComputerUseOwners(session, toolArgs), + owners: resolveComputerUseOwners(session, { + ...toolArgs, + ...(authorized.laneId ? { laneId: authorized.laneId } : {}), + }), }); return result; } diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index 768b75654..b522c54ce 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -167,6 +167,7 @@ describe("computerUseArtifactBrokerService", () => { backend: { name: "agent-browser", }, + callerRoot: path.dirname(blockedPath), inputs: [ { kind: "console_logs", @@ -300,6 +301,53 @@ describe("computerUseArtifactBrokerService", () => { expect(broker.listArtifacts({ artifactId: stored.id })[0]?.availability).toBe("available"); }); + it("imports proof from a server-authorized attached lane root", () => { + const attachedLaneRoot = fs.mkdtempSync(path.join(process.cwd(), ".attached-lane-proof-")); + try { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + fs.mkdirSync(path.join(attachedLaneRoot, "shots"), { recursive: true }); + fs.writeFileSync( + path.join(attachedLaneRoot, "shots", "proof.png"), + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ); + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, attached_root_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "attached-lane", + "project-1", + "Attached lane", + "main", + "feature/attached", + attachedLaneRoot, + attachedLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: attachedLaneRoot, + owners: [{ kind: "lane", id: "attached-lane" }], + inputs: [{ kind: "screenshot", title: "Attached proof", path: "shots/proof.png" }], + }); + + expect(broker.listArtifacts({ artifactId: ingested.artifacts[0]!.id })[0]?.availability) + .toBe("available"); + } finally { + fs.rmSync(attachedLaneRoot, { recursive: true, force: true }); + } + }); + it("throws instead of persisting a record when the capture file is not found", () => { const broker = createComputerUseArtifactBrokerService({ db, @@ -647,6 +695,63 @@ describe("computerUseArtifactBrokerService", () => { expect(fs.readFileSync(path.join(projectRoot, recovered.uri), "utf8")).toBe("lane-a"); }); + it("recovers proof from an attached lane root outside the project", () => { + const attachedLaneRoot = fs.mkdtempSync(path.join(process.cwd(), ".attached-lane-recovery-")); + try { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + fs.mkdirSync(path.join(attachedLaneRoot, "shots"), { recursive: true }); + fs.writeFileSync( + path.join(attachedLaneRoot, "shots", "proof.png"), + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ); + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, attached_root_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "attached-lane", + "project-1", + "Attached lane", + "main", + "feature/attached", + attachedLaneRoot, + attachedLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, ?, 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, ?, ?, ?)`, + [ + "attached-broken", + "project-1", + "Attached broken proof", + "shots/proof.png", + JSON.stringify({ sourcePath: "shots/proof.png", callerRoot: attachedLaneRoot }), + "attached-lane", + "2026-03-12T14:00:00.000Z", + ], + ); + + const recovered = broker.recoverArtifact({ artifactId: "attached-broken" }); + expect(recovered.availability).toBe("available"); + expect(fs.existsSync(path.join(projectRoot, recovered.uri))).toBe(true); + } finally { + fs.rmSync(attachedLaneRoot, { recursive: true, force: true }); + } + }); + it("prunes broken records that cannot be recovered", () => { const broker = createComputerUseArtifactBrokerService({ db, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 73acb3e1d..459cb8cb1 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -395,6 +395,7 @@ export function createComputerUseArtifactBrokerService(args: { kind: ComputerUseArtifactKind, title: string, callerRoot: string | null, + requestImportRoots: string[], ): ResolvedStoredArtifact => { const directUri = toOptionalString(input.uri); if (directUri && isHttpUrl(directUri)) { @@ -464,7 +465,7 @@ export function createComputerUseArtifactBrokerService(args: { } catch { // Fall through to external import handling. } - if (!isAllowedExternalArtifactSource(absolutePath, allowedImportRoots)) { + if (!isAllowedExternalArtifactSource(absolutePath, [...allowedImportRoots, ...requestImportRoots])) { throw new Error(`Artifact path is outside allowed import roots: ${absolutePath}`); } const extension = inferArtifactExtension({ ...input, path: absolutePath }, kind); @@ -614,21 +615,7 @@ export function createComputerUseArtifactBrokerService(args: { * caller's path and root in metadata, so a capture that was never imported * can be re-imported as long as its lane worktree still exists. */ - const resolveArtifactLaneRoots = (record: ComputerUseArtifactRecord): string[] => { - const laneIds = new Set(); - if (record.laneId) laneIds.add(record.laneId); - for (const link of readLinkRows([record.id])) { - if (link.ownerKind === "lane") { - laneIds.add(link.ownerId); - continue; - } - if (link.ownerKind !== "chat_session") continue; - const session = db.get<{ lane_id: string | null }>( - "select lane_id from terminal_sessions where id = ? limit 1", - [link.ownerId], - ); - if (session?.lane_id) laneIds.add(session.lane_id); - } + const resolveLaneRoots = (laneIds: Set): string[] => { if (!laneIds.size) return []; const placeholders = [...laneIds].map(() => "?").join(", "); @@ -649,6 +636,24 @@ export function createComputerUseArtifactBrokerService(args: { )).sort(); }; + const resolveArtifactLaneRoots = (record: ComputerUseArtifactRecord): string[] => { + const laneIds = new Set(); + if (record.laneId) laneIds.add(record.laneId); + for (const link of readLinkRows([record.id])) { + if (link.ownerKind === "lane") { + laneIds.add(link.ownerId); + continue; + } + if (link.ownerKind !== "chat_session") continue; + const session = db.get<{ lane_id: string | null }>( + "select lane_id from terminal_sessions where id = ? limit 1", + [link.ownerId], + ); + if (session?.lane_id) laneIds.add(session.lane_id); + } + return resolveLaneRoots(laneIds); + }; + const resolveCandidateAcrossRoots = ( candidate: string, roots: string[], @@ -999,6 +1004,10 @@ export function createComputerUseArtifactBrokerService(args: { const owners = dedupeOwners(request.owners ?? []); const callerRoot = toOptionalString(request.callerRoot); const laneId = resolveLaneIdForOwners(owners); + // The broker accepts extra roots only from its own lane table. The RPC + // supplies `callerRoot` after authenticating it, but callers cannot turn + // an arbitrary path into an import root merely by placing it in metadata. + const requestImportRoots = laneId ? resolveLaneRoots(new Set([laneId])) : []; // Resolve every input before persisting any of them. `resolveStoredUri` // now throws (missing file, non-importable type, denied source), and a // half-committed batch would leave the agent's retry inserting the @@ -1014,7 +1023,7 @@ export function createComputerUseArtifactBrokerService(args: { for (const input of request.inputs) { const kind = normalizeInputKind(input); const title = toOptionalString(input.title) ?? defaultTitleForKind(kind); - const stored = resolveStoredUri(input, kind, title, callerRoot); + const stored = resolveStoredUri(input, kind, title, callerRoot, requestImportRoots); if (stored.stagedFilePath) stagedFilePaths.push(stored.stagedFilePath); resolved.push({ input, kind, title, stored }); } @@ -1170,7 +1179,8 @@ export function createComputerUseArtifactBrokerService(args: { ); } const sourcePath = source.path; - if (!isAllowedExternalArtifactSource(sourcePath, allowedImportRoots) + const artifactLaneRoots = resolveArtifactLaneRoots(record); + if (!isAllowedExternalArtifactSource(sourcePath, [...allowedImportRoots, ...artifactLaneRoots]) || isDeniedArtifactSource(sourcePath, deniedImportRoots)) { throw new Error(`Artifact path is outside allowed import roots: ${sourcePath}`); } From 8483c7c4330259e12ef2c96b7b7dec4042b01269 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:44:44 -0400 Subject: [PATCH 18/20] Fix proof lifecycle and cleanup recovery --- apps/ade-cli/src/adeRpcServer.test.ts | 87 +++++++++++++++++++ apps/ade-cli/src/adeRpcServer.ts | 3 +- .../computerUseArtifactBrokerService.test.ts | 8 +- .../storage/storageInsightsService.test.ts | 36 ++++++++ .../storage/storageInsightsService.ts | 41 +++++---- .../chat/chatTranscriptRows.test.ts | 35 ++++++++ .../components/chat/chatTranscriptRows.ts | 13 +-- 7 files changed, 195 insertions(+), 28 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 62463d7e3..d29a83167 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1386,6 +1386,93 @@ describe("adeRpcServer", () => { }); }); + it("gives standalone ade CLI callers project-wide proof lifecycle scope without widening unbound agents", async () => { + const standaloneFixture = createRuntime(); + const standaloneHandler = createAdeRpcRequestHandler({ + runtime: standaloneFixture.runtime, + serverVersion: "test", + }); + const artifacts = [ + { id: "proof-1", createdAt: "2026-07-29T01:00:00.000Z", laneId: "lane-1", links: [] }, + { id: "proof-2", createdAt: "2026-07-29T02:00:00.000Z", laneId: "lane-2", links: [] }, + ]; + standaloneFixture.runtime.computerUseArtifactBrokerService.listArtifacts = vi.fn((args: any) => + args.artifactId ? artifacts.filter((artifact) => artifact.id === args.artifactId) : artifacts + ); + standaloneFixture.runtime.computerUseArtifactBrokerService.deleteArtifacts = vi.fn(() => ({ + deleted: [], + missing: [], + failed: [], + freedBytes: 0, + })); + standaloneFixture.runtime.computerUseArtifactBrokerService.listBrokenArtifacts = vi.fn((args: any) => [ + { artifactId: "proof-1" }, + { artifactId: "proof-2" }, + ].slice(0, args.limit)); + standaloneFixture.runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts = vi.fn(() => ({ + deleted: ["proof-1", "proof-2"], + missing: [], + failed: [], + freedBytes: 42, + })); + standaloneFixture.runtime.computerUseArtifactBrokerService.recoverArtifact = vi.fn(() => ({ + recovered: true, + })); + await initialize(standaloneHandler, { + callerId: "ade-cli:4242", + role: "agent", + }); + + const listed = await callTool(standaloneHandler, "list_computer_use_artifacts", {}); + expect(listed.structuredContent.artifacts).toEqual(artifacts); + + await callTool(standaloneHandler, "delete_computer_use_artifacts", { + artifactId: "proof-2", + }); + expect(standaloneFixture.runtime.computerUseArtifactBrokerService.deleteArtifacts).toHaveBeenCalledWith({ + artifactIds: ["proof-2"], + }); + + const broken = await callTool(standaloneHandler, "list_broken_computer_use_artifacts", { + limit: 1, + }); + expect(broken.structuredContent.broken).toEqual([{ artifactId: "proof-1" }]); + expect(standaloneFixture.runtime.computerUseArtifactBrokerService.listBrokenArtifacts).toHaveBeenCalledWith({ + limit: 1, + }); + + await callTool(standaloneHandler, "prune_broken_computer_use_artifacts", {}); + expect(standaloneFixture.runtime.computerUseArtifactBrokerService.pruneBrokenArtifacts).toHaveBeenCalledTimes(1); + + await callTool(standaloneHandler, "recover_computer_use_artifact", { + artifactId: "proof-2", + }); + expect(standaloneFixture.runtime.computerUseArtifactBrokerService.recoverArtifact).toHaveBeenCalledWith({ + artifactId: "proof-2", + }); + + const unboundAgentFixture = createRuntime(); + const unboundAgentHandler = createAdeRpcRequestHandler({ + runtime: unboundAgentFixture.runtime, + serverVersion: "test", + }); + await initialize(unboundAgentHandler, { + callerId: "unbound-agent", + role: "agent", + }); + + for (const [name, args] of [ + ["list_computer_use_artifacts", {}], + ["delete_computer_use_artifacts", { artifactId: "proof-1" }], + ["list_broken_computer_use_artifacts", {}], + ["prune_broken_computer_use_artifacts", {}], + ["recover_computer_use_artifact", { artifactId: "proof-1" }], + ] as const) { + const denied = await callTool(unboundAgentHandler, name, args); + expect(denied.isError).toBe(true); + } + }); + it("sorts the scoped proof union before applying its limit", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index e4f6a675b..2fa0afbad 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2175,7 +2175,8 @@ async function resolveAuthorizedComputerUseIngestRoot( } function isProjectWideProofMaintenanceAuthorized(session: SessionState): boolean { - return session.identity.role === "cto" && isUserClientSession(session); + return (session.identity.role === "cto" && isUserClientSession(session)) + || isUnboundAdeCliCaller(session); } function resolveAuthorizedProofOwners( diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index b522c54ce..e7e30a168 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -304,6 +304,7 @@ describe("computerUseArtifactBrokerService", () => { it("imports proof from a server-authorized attached lane root", () => { const attachedLaneRoot = fs.mkdtempSync(path.join(process.cwd(), ".attached-lane-proof-")); try { + const proofBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const broker = createComputerUseArtifactBrokerService({ db, projectId: "project-1", @@ -313,7 +314,7 @@ describe("computerUseArtifactBrokerService", () => { fs.mkdirSync(path.join(attachedLaneRoot, "shots"), { recursive: true }); fs.writeFileSync( path.join(attachedLaneRoot, "shots", "proof.png"), - Buffer.from([0x89, 0x50, 0x4e, 0x47]), + proofBytes, ); db.run( ` @@ -343,6 +344,7 @@ describe("computerUseArtifactBrokerService", () => { expect(broker.listArtifacts({ artifactId: ingested.artifacts[0]!.id })[0]?.availability) .toBe("available"); + expect(fs.readFileSync(path.join(projectRoot, ingested.artifacts[0]!.uri))).toEqual(proofBytes); } finally { fs.rmSync(attachedLaneRoot, { recursive: true, force: true }); } @@ -698,6 +700,7 @@ describe("computerUseArtifactBrokerService", () => { it("recovers proof from an attached lane root outside the project", () => { const attachedLaneRoot = fs.mkdtempSync(path.join(process.cwd(), ".attached-lane-recovery-")); try { + const proofBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const broker = createComputerUseArtifactBrokerService({ db, projectId: "project-1", @@ -707,7 +710,7 @@ describe("computerUseArtifactBrokerService", () => { fs.mkdirSync(path.join(attachedLaneRoot, "shots"), { recursive: true }); fs.writeFileSync( path.join(attachedLaneRoot, "shots", "proof.png"), - Buffer.from([0x89, 0x50, 0x4e, 0x47]), + proofBytes, ); db.run( ` @@ -747,6 +750,7 @@ describe("computerUseArtifactBrokerService", () => { const recovered = broker.recoverArtifact({ artifactId: "attached-broken" }); expect(recovered.availability).toBe("available"); expect(fs.existsSync(path.join(projectRoot, recovered.uri))).toBe(true); + expect(fs.readFileSync(path.join(projectRoot, recovered.uri))).toEqual(proofBytes); } finally { fs.rmSync(attachedLaneRoot, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts index 988fb1389..2039ac4e4 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts @@ -15,6 +15,7 @@ import { STORAGE_LEDGER, } from "./storageLedger"; import { recordLastFailure } from "../runtime/lastFailureStore"; +import { createPromptStash } from "../chat/promptStashService"; const logger = { debug: vi.fn(), @@ -388,6 +389,41 @@ describe("storageInsightsService", () => { expect(purged).toEqual([proofDir]); }); + it("never offers the live composer attachment store for recursive cleanup", async () => { + const attachmentsDir = path.join(projectRoot, ".ade", "attachments"); + const stashedImage = path.join(attachmentsDir, "stashed-image.png"); + const activeDraftImage = path.join(attachmentsDir, "active-draft.png"); + writeSized(stashedImage, 17); + writeSized(activeDraftImage, 19); + createPromptStash(db, { + text: "Keep this image", + attachments: [{ path: stashedImage, type: "image" }], + }); + const service = createStorageInsightsService({ projectRoot, adeHome, db, logger }); + + const snapshot = await service.getSnapshot(); + expect(snapshot.categories.find((category) => category.id === "proof_attachments")?.items) + .not.toContainEqual(expect.objectContaining({ path: attachmentsDir })); + + const preview = await service.cleanupPreview([ + { kind: "proof_attachments", path: attachmentsDir }, + { kind: "proof_attachments", path: activeDraftImage }, + ]); + expect(preview.items).toEqual([]); + expect(preview.blocked).toEqual([ + { + path: attachmentsDir, + reason: "Composer attachments are live chat data and cannot be removed from Storage.", + }, + { + path: activeDraftImage, + reason: "Composer attachments are live chat data and cannot be removed from Storage.", + }, + ]); + expect(fs.existsSync(stashedImage)).toBe(true); + expect(fs.existsSync(activeDraftImage)).toBe(true); + }); + it("refuses proof cleanup for the shared artifacts parent", async () => { const artifactsDir = path.join(projectRoot, ".ade", "artifacts"); writeSized(path.join(artifactsDir, "packs", "lane-pack.tar"), 4); diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.ts b/apps/desktop/src/main/services/storage/storageInsightsService.ts index b1d7fa159..09fbffdc1 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.ts @@ -805,19 +805,15 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti }))?.item); } - for (const [proofPath, label] of [ - [path.join(layout.artifactsDir, "computer-use"), "Proof and recordings"], - [path.join(layout.adeDir, "attachments"), "Attachments"], - ] as const) { - add("proof_attachments", (await makeItem({ - category: "proof_attachments", - base: layout.adeDir, - path: proofPath, - label, - safety: "review_first", - state, - }))?.item); - } + const proofPath = path.join(layout.artifactsDir, "computer-use"); + add("proof_attachments", (await makeItem({ + category: "proof_attachments", + base: layout.adeDir, + path: proofPath, + label: "Proof and recordings", + safety: "review_first", + state, + }))?.item); const adeNames = await readdirOrEmpty(layout.adeDir); for (const name of adeNames.filter((value) => RECOVERY_BACKUP_PATTERN.test(value))) { @@ -1027,15 +1023,22 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti if (await hasSymlinkAncestor(projectRoot, targetPath)) { return { valid: null, reason: "Links cannot be used in a cleanup path." }; } + const attachmentsRoot = path.join(layout.adeDir, "attachments"); + if (isSameOrWithin(attachmentsRoot, targetPath)) { + // This store backs prompt stashes and renderer-owned composer drafts. + // Main cannot enumerate every active draft, so recursive or per-file + // cleanup here could delete live user input. + return { + valid: null, + reason: "Composer attachments are live chat data and cannot be removed from Storage.", + }; + } // Same jail the broker and the `ade-artifact://` handler enforce. - const proofRoots = [ - path.join(layout.artifactsDir, "computer-use"), - path.join(layout.adeDir, "attachments"), - ]; - if (!proofRoots.some((root) => isSameOrWithin(root, targetPath))) { + const proofRoot = path.join(layout.artifactsDir, "computer-use"); + if (!isSameOrWithin(proofRoot, targetPath)) { return { valid: null, reason: "This path is not proof or attachment storage." }; } - label = isSameOrWithin(proofRoots[0], targetPath) ? "Proof and recordings" : "Attachments"; + label = "Proof and recordings"; } else { return { valid: null, reason: "This cleanup target is not supported." }; } diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 599c7b8f2..3d32aeba2 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -3010,6 +3010,41 @@ describe("ade_card transcript rows", () => { expect(merged.actions).toEqual([]); }); + it("clears stale degradation state when a healthy detail refresh is genuinely empty", () => { + const emptyProgress = { passed: 0, failed: 0, running: 0, queued: 0 }; + const rows = collapseChatTranscriptEvents([ + env("2026-07-27T10:00:00.000Z", card({ + variant: "pr_ci", + rows: [{ icon: "fail", text: "lint" }], + metrics: [{ label: "failed", value: "1" }], + progress: { ...emptyProgress, failed: 1 }, + })), + env("2026-07-27T10:00:01.000Z", card({ + variant: "pr_ci", + rows: [], + metrics: [], + progress: emptyProgress, + degradedReason: "HTTP 403", + actions: [{ id: "retry", label: "Retry", kind: "primary" }], + })), + env("2026-07-27T10:00:02.000Z", card({ + variant: "pr_ci", + rows: [], + metrics: [], + progress: emptyProgress, + })), + ]); + + const merged = rows[0]!.event; + if (merged.type !== "ade_card") throw new Error("Expected ade_card"); + expect(merged.stale).toBe(false); + expect(merged.rows).toEqual([]); + expect(merged.metrics).toEqual([]); + expect(merged.progress).toEqual(emptyProgress); + expect(merged.degradedReason).toBeUndefined(); + expect(merged.actions).toEqual([]); + }); + it("keeps distinct cardIds as distinct rows", () => { const rows = collapseChatTranscriptEvents([ env("2026-07-27T10:00:00.000Z", card({ cardId: "run-1" })), diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index e98908a0f..3f6d23ed0 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -1053,7 +1053,8 @@ type AdeCardEvent = Extract; * flagged `stale` so the surface can say "this is the last thing we knew" * rather than silently showing old numbers as current. * - * The flag clears itself the moment a healthy emit brings detail back. + * The flag clears itself the moment a healthy detail refresh lands, including + * a successful refresh whose rows and totals are genuinely empty. */ function mergeAdeCardEvent( existing: AdeCardEvent, @@ -1068,11 +1069,11 @@ function mergeAdeCardEvent( const existingHadDetail = (existing.rows?.length ?? 0) > 0 || (existing.metrics?.length ?? 0) > 0 || adeCardProgressTotal(existing.progress) > 0; - const incomingHasDetail = incomingRows.length > 0 - || incomingMetrics.length > 0 - || incomingProgressTotal > 0; + const incomingIsDetailRefresh = incoming.rows !== undefined + || incoming.metrics !== undefined + || incoming.progress !== undefined; - if (existingHadDetail && (!incomingHasDetail || incoming.degradedReason)) { + if (existingHadDetail && incoming.degradedReason) { if (!incomingRows.length && existing.rows?.length) merged.rows = existing.rows; if (!incomingMetrics.length && existing.metrics?.length) merged.metrics = existing.metrics; if (incomingProgressTotal === 0 && existing.progress) merged.progress = existing.progress; @@ -1086,7 +1087,7 @@ function mergeAdeCardEvent( // Healthy emit: drop every degradation-only field the previous failed fetch // left behind. Full healthy card payloads omit the retry action and reason, // so the initial spread cannot distinguish recovery from a partial patch. - if (incomingHasDetail) { + if (incomingIsDetailRefresh && !incoming.degradedReason) { merged.stale = incoming.stale ?? false; merged.degradedReason = incoming.degradedReason ?? undefined; merged.actions = incoming.actions ?? []; From 5971a1e130681d91bf9b3ee7a9e7005af54e94b5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:04:40 -0400 Subject: [PATCH 19/20] Harden proof recovery provenance --- .../computerUseArtifactBrokerService.test.ts | 108 ++++++++++++++++++ .../computerUseArtifactBrokerService.ts | 6 +- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index e7e30a168..fd91c4435 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -697,6 +697,114 @@ describe("computerUseArtifactBrokerService", () => { expect(fs.readFileSync(path.join(projectRoot, recovered.uri), "utf8")).toBe("lane-a"); }); + it("does not recover from a caller-supplied absolutePath in another lane", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const owningLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-a"); + const otherLaneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-b"); + const originalPath = path.join(owningLaneRoot, "shots", "proof.png"); + const substitutePath = path.join(otherLaneRoot, "shots", "substitute.png"); + fs.mkdirSync(path.dirname(originalPath), { recursive: true }); + fs.mkdirSync(path.dirname(substitutePath), { recursive: true }); + fs.writeFileSync(originalPath, "original", "utf8"); + fs.writeFileSync(substitutePath, "substitute", "utf8"); + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "lane-a", + "project-1", + "Lane A", + "main", + "feature/lane-a", + owningLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: owningLaneRoot, + owners: [{ kind: "lane", id: "lane-a" }], + inputs: [{ + kind: "screenshot", + title: "Lane-scoped proof", + path: "shots/proof.png", + metadata: { absolutePath: substitutePath }, + }], + }).artifacts[0]!; + fs.rmSync(path.join(projectRoot, ingested.uri), { force: true }); + fs.rmSync(originalPath, { force: true }); + + expect(broker.listBrokenArtifacts()[0]).toMatchObject({ + artifactId: ingested.id, + recoverablePath: null, + }); + expect(() => broker.recoverArtifact({ artifactId: ingested.id })) + .toThrow(/original file.*no longer exists/i); + }); + + it("recovers a local URI capture from its attached lane root", () => { + const attachedLaneRoot = fs.mkdtempSync(path.join(process.cwd(), ".attached-uri-recovery-")); + try { + const proofBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const originalPath = path.join(attachedLaneRoot, "shots", "proof.png"); + fs.mkdirSync(path.dirname(originalPath), { recursive: true }); + fs.writeFileSync(originalPath, proofBytes); + db.run( + ` + insert into lanes( + id, project_id, name, base_ref, branch_ref, worktree_path, attached_root_path, status, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "attached-uri-lane", + "project-1", + "Attached URI lane", + "main", + "feature/attached-uri", + attachedLaneRoot, + attachedLaneRoot, + "active", + "2026-03-12T14:00:00.000Z", + ], + ); + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + logger: createLogger(), + }); + const ingested = broker.ingest({ + backend: { name: "ade-cli", style: "manual" }, + callerRoot: attachedLaneRoot, + owners: [{ kind: "lane", id: "attached-uri-lane" }], + inputs: [{ + kind: "screenshot", + title: "Attached URI proof", + uri: "shots/proof.png", + }], + }).artifacts[0]!; + fs.rmSync(path.join(projectRoot, ingested.uri), { force: true }); + + expect(broker.listBrokenArtifacts()[0]?.recoverablePath) + .toBe(fs.realpathSync(originalPath)); + const recovered = broker.recoverArtifact({ artifactId: ingested.id }); + expect(recovered.availability).toBe("available"); + expect(fs.readFileSync(path.join(projectRoot, recovered.uri))).toEqual(proofBytes); + } finally { + fs.rmSync(attachedLaneRoot, { recursive: true, force: true }); + } + }); + it("recovers proof from an attached lane root outside the project", () => { const attachedLaneRoot = fs.mkdtempSync(path.join(process.cwd(), ".attached-lane-recovery-")); try { diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 459cb8cb1..209c80f25 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -612,8 +612,8 @@ export function createComputerUseArtifactBrokerService(args: { /** * Where a broken record's original bytes might still be. Ingest records the - * caller's path and root in metadata, so a capture that was never imported - * can be re-imported as long as its lane worktree still exists. + * validated path/URI and caller root in broker-stamped metadata, so a capture + * can be re-imported as long as its authorized lane worktree still exists. */ const resolveLaneRoots = (laneIds: Set): string[] => { if (!laneIds.size) return []; @@ -682,8 +682,8 @@ export function createComputerUseArtifactBrokerService(args: { if (!trimmed || isHttpUrl(trimmed)) return; if (!candidates.includes(trimmed)) candidates.push(trimmed); }; - push(toOptionalString(record.metadata?.absolutePath)); push(toOptionalString(record.metadata?.sourcePath)); + push(toOptionalString(record.metadata?.sourceUri)); push(record.uri); const metadataCallerRoot = toOptionalString(record.metadata?.callerRoot); From e8f2fc2f8a7382a5070bc0cf9937fb7089dd6d13 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:23:45 -0400 Subject: [PATCH 20/20] Fix proof ingestion caller roots --- apps/ade-cli/src/cli.test.ts | 24 +++++++++++++ apps/ade-cli/src/cli.ts | 3 +- .../chat/ChatComputerUsePanel.test.tsx | 34 ++++++++++++++++++- .../components/chat/ChatComputerUsePanel.tsx | 16 +++++++-- 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 2dac03d13..70d2084c7 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -5922,6 +5922,29 @@ describe("ADE CLI", () => { }); }); + it("passes the caller cwd when ingesting proof directly", () => { + const plan = buildCliPlan([ + "proof", + "ingest", + "--input-json", + JSON.stringify({ + backendStyle: "external_cli", + backendName: "agent-browser", + inputs: [{ kind: "screenshot", path: "shots/proof.png" }], + }), + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") throw new Error("Expected proof ingest to produce an execute plan"); + + expect(plan.steps[0]?.params).toMatchObject({ + name: "ingest_computer_use_artifacts", + arguments: { + callerRoot: process.cwd(), + inputs: [{ kind: "screenshot", path: "shots/proof.png" }], + }, + }); + }); + it("resolves a relative proof attach path against the caller's cwd", () => { // The agent's cwd is its lane worktree; the runtime storing the artifact // runs at the project root. Resolving here is what stops the runtime from @@ -9959,6 +9982,7 @@ describe("ADE CLI", () => { arguments: { backendName: "ade-browser", toolName: "browser proof", + callerRoot: process.cwd(), inputs: [ { kind: "screenshot", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 714e56ba4..4fb670fe0 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -8749,7 +8749,7 @@ function buildProofPlan(args: string[]): CliPlan { actionCallStep( "result", "ingest_computer_use_artifacts", - collectGenericObjectArgs(args), + collectGenericObjectArgs(args, { callerRoot: process.cwd() }), ), ], }; @@ -10445,6 +10445,7 @@ function buildBrowserPlan(args: string[]): CliPlan { backendStyle: "manual", backendName: "ade-browser", toolName: "browser proof", + callerRoot: process.cwd(), ...ownerBase, inputs: [ { diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx index 5d8497396..27ca030e4 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.test.tsx @@ -187,7 +187,10 @@ describe("proof rendering", () => { title: "Lost proof", availability: "unimported", uri: "shots/proof.png", - metadata: { sourcePath: "shots/proof.png" }, + metadata: { + sourcePath: "shots/proof.png", + callerRoot: "/project/.ade/worktrees/lane-a", + }, }); render(); @@ -201,6 +204,35 @@ describe("proof rendering", () => { await waitFor(() => expect(recoverArtifact).toHaveBeenCalledWith({ artifactId: "artifact-4" })); }); + it("offers recovery for a local source URI but not an HTTP source URI", async () => { + const recoverArtifact = vi.fn().mockResolvedValue({}); + (window.ade as any).computerUse.recoverArtifact = recoverArtifact; + + const localUri = artifact(8, { + title: "URI proof", + availability: "unimported", + metadata: { + sourceUri: "shots/from-uri.png", + callerRoot: "/project/.ade/worktrees/lane-b", + }, + }); + const remoteUri = artifact(9, { + title: "Remote proof", + availability: "unimported", + metadata: { sourceUri: "https://example.com/proof.png" }, + }); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Locate URI proof in its lane" })); + await waitFor(() => expect(recoverArtifact).toHaveBeenCalledWith({ artifactId: "artifact-8" })); + expect(screen.queryByRole("button", { name: "Locate Remote proof in its lane" })).toBeNull(); + }); + it("surfaces a failed delete instead of silently doing nothing", async () => { (window.ade as any).computerUse.deleteArtifacts = vi.fn().mockRejectedValue(new Error("Artifact is locked")); diff --git a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx index 01ea26e52..bad6bd40f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx @@ -67,9 +67,19 @@ function isBrokenArtifact(artifact: ComputerUseArtifactView): boolean { return artifactAvailability(artifact) !== "available"; } +function localSourceValue(value: unknown): string | null { + const source = typeof value === "string" ? value.trim() : ""; + return source && !externalArtifactUrl(source) ? source : null; +} + +function recoverableArtifactSource(artifact: ComputerUseArtifactView): string | null { + return localSourceValue(artifact.metadata?.sourcePath) + ?? localSourceValue(artifact.metadata?.sourceUri); +} + function shortSourcePath(artifact: ComputerUseArtifactView): string | null { - const source = artifact.metadata?.sourcePath ?? artifact.metadata?.absolutePath; - const value = typeof source === "string" ? source.trim() : ""; + const value = recoverableArtifactSource(artifact) + ?? localSourceValue(artifact.metadata?.absolutePath); if (!value) return null; const segments = value.split(/[\\/]/).filter(Boolean); return segments.length > 2 ? `…/${segments.slice(-2).join("/")}` : value; @@ -470,7 +480,7 @@ function DrawerProofTile({ const previewUnavailable = !storedFileMissing && (mediaFailed || (loaded && !preview && (image || video))); const hasPreviewProblem = storedFileMissing || previewUnavailable; - const recoverable = typeof artifact.metadata?.sourcePath === "string"; + const recoverable = recoverableArtifactSource(artifact) !== null; useEffect(() => { setMediaFailed(false);