From 243417667f7de8eb0074a2453dfc41a8370855ae Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Wed, 5 Aug 2026 13:05:28 -0400 Subject: [PATCH 01/14] feat(desktop): add trustworthy session timeline Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../agents/ui/CausalSessionTimeline.tsx | 89 +++++++++ .../ui/trustworthySessionTimeline.test.mjs | 68 +++++++ .../agents/ui/trustworthySessionTimeline.ts | 171 ++++++++++++++++++ .../channels/ui/AgentSessionThreadPanel.tsx | 5 + 4 files changed, 333 insertions(+) create mode 100644 desktop/src/features/agents/ui/CausalSessionTimeline.tsx create mode 100644 desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs create mode 100644 desktop/src/features/agents/ui/trustworthySessionTimeline.ts diff --git a/desktop/src/features/agents/ui/CausalSessionTimeline.tsx b/desktop/src/features/agents/ui/CausalSessionTimeline.tsx new file mode 100644 index 0000000000..8c9baa7318 --- /dev/null +++ b/desktop/src/features/agents/ui/CausalSessionTimeline.tsx @@ -0,0 +1,89 @@ +import { AlertTriangle, Eye, GitBranch, ShieldCheck } from "lucide-react"; +import * as React from "react"; + +import { Badge } from "@/shared/ui/badge"; +import { cn } from "@/shared/lib/cn"; + +import type { ObserverEvent } from "./agentSessionTypes"; +import type { CausalFinding } from "./trustworthySessionTimeline"; +import { buildTrustworthySessionTimeline } from "./trustworthySessionTimeline"; + +export function CausalSessionTimeline({ + events, + findings, +}: { + events: readonly ObserverEvent[]; + findings: readonly CausalFinding[]; +}) { + const timeline = React.useMemo( + () => buildTrustworthySessionTimeline(events, findings), + [events, findings], + ); + if (timeline.length === 0) return null; + + const gaps = timeline.filter((event) => event.class === "gap"); + const visible = timeline.slice(-12); + + return ( +
+
+
+

+ Why did this agent behave this way? +

+

+ Evidence and known visibility gaps, ordered across observing + systems. +

+
+ 0 ? "outline" : "secondary"}> + {gaps.length > 0 + ? `${gaps.length} coverage gap${gaps.length === 1 ? "" : "s"}` + : "Complete coverage"} + +
+
    + {visible.map((event) => { + const Icon = + event.class === "gap" + ? AlertTriangle + : event.class === "decision" + ? ShieldCheck + : event.class === "inference" + ? GitBranch + : Eye; + return ( +
  1. +
    + +
    +
    + {event.title} + {event.sourceSystem} + + {event.observerRole} + +
    +

    {event.detail}

    +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs b/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs new file mode 100644 index 0000000000..9d4c52058b --- /dev/null +++ b/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildTrustworthySessionTimeline } from "./trustworthySessionTimeline.ts"; + +const base = { + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", +}; + +test("projects enforcement provenance and explicit uncovered layers", () => { + const events = buildTrustworthySessionTimeline( + [ + { + ...base, + seq: 1, + timestamp: "2026-08-05T12:00:00Z", + kind: "permission_decision", + payload: { mode: "lockdown", decision: "reject_once" }, + }, + ], + [], + ); + + const decision = events.find((event) => event.class === "decision"); + assert.equal(decision.title, "Tool request denied"); + assert.equal(decision.sourceSystem, "Buzz ACP"); + assert.equal(decision.observerRole, "enforced"); + assert.equal(decision.confidence, "direct"); + + assert.deepEqual( + events + .filter((event) => event.class === "gap") + .map((event) => event.sourceLayer), + ["host_workspace", "os_sandbox"], + ); +}); + +test("keeps Numbat conclusions distinct from directly observed facts", () => { + const events = buildTrustworthySessionTimeline( + [], + [ + { + findingId: "finding-1", + ruleId: "guardian.suspicious-write", + title: "Suspicious write", + severity: "high", + detectedAt: "2026-08-05T12:00:01Z", + sourceAgent: "agent-1", + sessionId: "session-1", + channelId: "channel-1", + turnId: "turn-1", + evidenceCount: 2, + }, + ], + ); + + const finding = events.find((event) => event.sourceSystem === "Numbat"); + assert.equal(finding.class, "inference"); + assert.equal(finding.observerRole, "inferred"); + assert.equal(finding.confidence, "correlated"); +}); + +test("does not invent gaps before a session has any evidence", () => { + assert.deepEqual(buildTrustworthySessionTimeline([], []), []); +}); diff --git a/desktop/src/features/agents/ui/trustworthySessionTimeline.ts b/desktop/src/features/agents/ui/trustworthySessionTimeline.ts new file mode 100644 index 0000000000..2c3dd7582e --- /dev/null +++ b/desktop/src/features/agents/ui/trustworthySessionTimeline.ts @@ -0,0 +1,171 @@ +import type { ObserverEvent } from "./agentSessionTypes"; + +export type CausalFinding = { + findingId: string; + detectedAt: string; + title: string; + ruleId: string; + evidenceCount: number; + sessionId: string | null; + turnId: string | null; +}; + +export type CausalEventClass = "fact" | "decision" | "inference" | "gap"; +export type CausalEventConfidence = "direct" | "correlated" | "unknown"; + +export type CausalTimelineEvent = { + id: string; + timestamp: string; + class: CausalEventClass; + title: string; + detail: string; + sourceSystem: string; + sourceLayer: string; + observerRole: "observed" | "decided" | "enforced" | "inferred" | "missing"; + confidence: CausalEventConfidence; + sessionId: string | null; + turnId: string | null; + evidenceIds: string[]; +}; + +const REQUIRED_SOURCE_LAYERS = [ + { layer: "host_workspace", system: "Host workspace tools" }, + { layer: "os_sandbox", system: "Operating-system sandbox" }, +] as const; + +function objectPayload(event: ObserverEvent): Record { + return event.payload && typeof event.payload === "object" + ? (event.payload as Record) + : {}; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function observerEventId(event: ObserverEvent): string { + return `observer:${event.sessionId ?? "unknown"}:${event.seq}`; +} + +function projectObserverEvent(event: ObserverEvent): CausalTimelineEvent { + const payload = objectPayload(event); + const eventId = observerEventId(event); + + if (event.kind === "permission_decision") { + const decision = stringValue(payload.decision) ?? "unknown"; + const mode = stringValue(payload.mode) ?? "unspecified policy"; + const denied = decision.includes("reject") || decision.includes("cancel"); + return { + id: eventId, + timestamp: event.timestamp, + class: "decision", + title: denied ? "Tool request denied" : "Tool request allowed", + detail: `Buzz ACP answered ${decision} under ${mode}.`, + sourceSystem: "Buzz ACP", + sourceLayer: "acp_permission_gate", + observerRole: "enforced", + confidence: "direct", + sessionId: event.sessionId, + turnId: event.turnId, + evidenceIds: [eventId], + }; + } + + if (event.kind === "turn_error") { + const message = + stringValue(payload.message) ?? + stringValue(payload.error) ?? + "No diagnostic was emitted."; + return { + id: eventId, + timestamp: event.timestamp, + class: "fact", + title: "Turn failed", + detail: message, + sourceSystem: "Buzz ACP", + sourceLayer: "acp_runtime", + observerRole: "observed", + confidence: "direct", + sessionId: event.sessionId, + turnId: event.turnId, + evidenceIds: [eventId], + }; + } + + return { + id: eventId, + timestamp: event.timestamp, + class: "fact", + title: event.kind.replaceAll("_", " "), + detail: "Captured by the Buzz ACP observer.", + sourceSystem: "Buzz ACP", + sourceLayer: "acp_observer", + observerRole: "observed", + confidence: "direct", + sessionId: event.sessionId, + turnId: event.turnId, + evidenceIds: [eventId], + }; +} + +function projectFinding(finding: CausalFinding): CausalTimelineEvent { + return { + id: `numbat:${finding.findingId}`, + timestamp: finding.detectedAt, + class: "inference", + title: finding.title, + detail: `${finding.ruleId} correlated ${finding.evidenceCount} evidence event${finding.evidenceCount === 1 ? "" : "s"}.`, + sourceSystem: "Numbat", + sourceLayer: "guardian_detection", + observerRole: "inferred", + confidence: finding.evidenceCount > 0 ? "correlated" : "unknown", + sessionId: finding.sessionId, + turnId: finding.turnId, + evidenceIds: [], + }; +} + +export function buildTrustworthySessionTimeline( + observerEvents: readonly ObserverEvent[], + findings: readonly CausalFinding[], +): CausalTimelineEvent[] { + if (observerEvents.length === 0 && findings.length === 0) return []; + + const projected = [ + ...observerEvents.map(projectObserverEvent), + ...findings.map(projectFinding), + ]; + const seenLayers = new Set(projected.map((event) => event.sourceLayer)); + const sessionId = + projected.find((event) => event.sessionId)?.sessionId ?? null; + const turnId = projected.find((event) => event.turnId)?.turnId ?? null; + const gapTimestamp = + projected + .map((event) => event.timestamp) + .filter(Boolean) + .sort()[0] ?? new Date(0).toISOString(); + + for (const source of REQUIRED_SOURCE_LAYERS) { + if (seenLayers.has(source.layer)) continue; + projected.push({ + id: `gap:${source.layer}:${sessionId ?? "unknown"}`, + timestamp: gapTimestamp, + class: "gap", + title: `${source.system} telemetry unavailable`, + detail: + "This execution layer is not connected to the Buzz session timeline. Actions may have occurred without appearing here.", + sourceSystem: source.system, + sourceLayer: source.layer, + observerRole: "missing", + confidence: "unknown", + sessionId, + turnId, + evidenceIds: [], + }); + } + + return projected.sort((left, right) => { + const byTime = Date.parse(left.timestamp) - Date.parse(right.timestamp); + return byTime || left.id.localeCompare(right.id); + }); +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 641b81490b..891e55299d 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -61,6 +61,7 @@ import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverE import { useLoadOlderOnScroll } from "@/features/messages/ui/useLoadOlderOnScroll"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { CausalSessionTimeline } from "@/features/agents/ui/CausalSessionTimeline"; type AgentSessionThreadPanelProps = { agent: ChannelAgentSessionAgent; @@ -492,6 +493,10 @@ export function AgentSessionThreadPanel({ >
+ Date: Wed, 5 Aug 2026 13:29:07 -0400 Subject: [PATCH 02/14] Lead agent sessions with causal explanations Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../agents/ui/CausalSessionTimeline.tsx | 193 +++++++++++++----- .../ui/trustworthySessionTimeline.test.mjs | 53 ++++- .../agents/ui/trustworthySessionTimeline.ts | 82 ++++++++ 3 files changed, 272 insertions(+), 56 deletions(-) diff --git a/desktop/src/features/agents/ui/CausalSessionTimeline.tsx b/desktop/src/features/agents/ui/CausalSessionTimeline.tsx index 8c9baa7318..755745a49b 100644 --- a/desktop/src/features/agents/ui/CausalSessionTimeline.tsx +++ b/desktop/src/features/agents/ui/CausalSessionTimeline.tsx @@ -1,4 +1,14 @@ -import { AlertTriangle, Eye, GitBranch, ShieldCheck } from "lucide-react"; +import { + AlertTriangle, + CheckCircle2, + CircleHelp, + Eye, + GitBranch, + Lightbulb, + LoaderCircle, + ShieldCheck, + XCircle, +} from "lucide-react"; import * as React from "react"; import { Badge } from "@/shared/ui/badge"; @@ -6,7 +16,7 @@ import { cn } from "@/shared/lib/cn"; import type { ObserverEvent } from "./agentSessionTypes"; import type { CausalFinding } from "./trustworthySessionTimeline"; -import { buildTrustworthySessionTimeline } from "./trustworthySessionTimeline"; +import { explainSession } from "./trustworthySessionTimeline"; export function CausalSessionTimeline({ events, @@ -15,75 +25,148 @@ export function CausalSessionTimeline({ events: readonly ObserverEvent[]; findings: readonly CausalFinding[]; }) { - const timeline = React.useMemo( - () => buildTrustworthySessionTimeline(events, findings), + const explanation = React.useMemo( + () => explainSession(events, findings), [events, findings], ); - if (timeline.length === 0) return null; + if (!explanation) return null; - const gaps = timeline.filter((event) => event.class === "gap"); - const visible = timeline.slice(-12); + const OutcomeIcon = + explanation.outcome === "failed" + ? XCircle + : explanation.outcome === "succeeded" + ? CheckCircle2 + : explanation.outcome === "in_progress" + ? LoaderCircle + : CircleHelp; return (
-
-
-

- Why did this agent behave this way? -

-

- Evidence and known visibility gaps, ordered across observing - systems. +

+

+ Task +

+

{explanation.task}

+
+ +
+
+

+ Outcome

+
+ + + {explanation.outcome.replace("_", " ")} + +
+
+
+
+

+ Why +

+ + {explanation.confidence} confidence + +
+

{explanation.why}

- 0 ? "outline" : "secondary"}> - {gaps.length > 0 - ? `${gaps.length} coverage gap${gaps.length === 1 ? "" : "s"}` - : "Complete coverage"} -
-
    - {visible.map((event) => { - const Icon = - event.class === "gap" - ? AlertTriangle - : event.class === "decision" + +
    +

    + Evidence +

    +
      + {explanation.evidence.map((event) => { + const Icon = + event.class === "decision" ? ShieldCheck : event.class === "inference" ? GitBranch : Eye; - return ( -
    1. -
      - + return ( +
    2. +
      -
      - {event.title} - {event.sourceSystem} - - {event.observerRole} - -
      -

      {event.detail}

      + {event.title} + + {" "} + · {event.sourceSystem} +
      -
    - - ); - })} -
+ + ); + })} + +
+ +
+

+ Unknowns +

+ {explanation.unknowns.length > 0 ? ( +
    + {explanation.unknowns.map((gap) => ( +
  • + + {gap.title} +
  • + ))} +
+ ) : ( +

+ No known evidence gaps. +

+ )} +
+ +
+
+ +
+

+ Suggested next action +

+

{explanation.nextAction}

+
+
+
+ +

+ Activity and raw telemetry continue below as supporting detail. +

); } diff --git a/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs b/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs index 9d4c52058b..35a3696747 100644 --- a/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs +++ b/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildTrustworthySessionTimeline } from "./trustworthySessionTimeline.ts"; +import { + buildTrustworthySessionTimeline, + explainSession, +} from "./trustworthySessionTimeline.ts"; const base = { agentIndex: 0, @@ -66,3 +69,51 @@ test("keeps Numbat conclusions distinct from directly observed facts", () => { test("does not invent gaps before a session has any evidence", () => { assert.deepEqual(buildTrustworthySessionTimeline([], []), []); }); + +test("explains a failed session before exposing raw telemetry", () => { + const explanation = explainSession( + [ + { + ...base, + seq: 1, + timestamp: "2026-08-05T12:00:00Z", + kind: "turn_started", + payload: {}, + }, + { + ...base, + seq: 2, + timestamp: "2026-08-05T12:00:02Z", + kind: "turn_error", + payload: { error: "cargo build exited 101" }, + }, + ], + [], + ); + + assert.equal(explanation.outcome, "failed"); + assert.equal(explanation.why, "cargo build exited 101"); + assert.equal(explanation.confidence, "medium"); + assert.match(explanation.nextAction, /failed event/i); + assert.equal(explanation.unknowns.length, 2); +}); + +test("does not invent a cause or fix when evidence is incomplete", () => { + const explanation = explainSession( + [ + { + ...base, + seq: 1, + timestamp: "2026-08-05T12:00:00Z", + kind: "turn_started", + payload: {}, + }, + ], + [], + ); + + assert.equal(explanation.outcome, "in_progress"); + assert.match(explanation.why, /still running/i); + assert.equal(explanation.confidence, "low"); + assert.match(explanation.nextAction, /missing outcome evidence/i); +}); diff --git a/desktop/src/features/agents/ui/trustworthySessionTimeline.ts b/desktop/src/features/agents/ui/trustworthySessionTimeline.ts index 2c3dd7582e..0dfd8739ed 100644 --- a/desktop/src/features/agents/ui/trustworthySessionTimeline.ts +++ b/desktop/src/features/agents/ui/trustworthySessionTimeline.ts @@ -28,6 +28,18 @@ export type CausalTimelineEvent = { evidenceIds: string[]; }; +export type SessionOutcome = "succeeded" | "failed" | "in_progress" | "unknown"; + +export type SessionExplanation = { + task: string; + outcome: SessionOutcome; + why: string; + confidence: "high" | "medium" | "low"; + evidence: CausalTimelineEvent[]; + unknowns: CausalTimelineEvent[]; + nextAction: string; +}; + const REQUIRED_SOURCE_LAYERS = [ { layer: "host_workspace", system: "Host workspace tools" }, { layer: "os_sandbox", system: "Operating-system sandbox" }, @@ -169,3 +181,73 @@ export function buildTrustworthySessionTimeline( return byTime || left.id.localeCompare(right.id); }); } + +export function explainSession( + observerEvents: readonly ObserverEvent[], + findings: readonly NumbatFinding[], +): SessionExplanation | null { + const timeline = buildTrustworthySessionTimeline(observerEvents, findings); + if (timeline.length === 0) return null; + + const latestError = [...timeline] + .reverse() + .find((event) => event.title === "Turn failed"); + const latestCompleted = [...observerEvents] + .reverse() + .find((event) => event.kind === "turn_completed"); + const latestStarted = [...observerEvents] + .reverse() + .find((event) => event.kind === "turn_started"); + const strongestFinding = [...timeline] + .reverse() + .find( + (event) => + event.class === "inference" && event.confidence === "correlated", + ); + const deniedDecision = [...timeline] + .reverse() + .find( + (event) => + event.class === "decision" && event.title === "Tool request denied", + ); + const gaps = timeline.filter((event) => event.class === "gap"); + const facts = timeline.filter((event) => event.class !== "gap"); + + const outcome: SessionOutcome = latestError + ? "failed" + : latestCompleted + ? "succeeded" + : latestStarted + ? "in_progress" + : "unknown"; + const cause = strongestFinding ?? latestError ?? deniedDecision ?? null; + const why = cause + ? cause.detail === "Captured by the Buzz ACP observer." + ? cause.title + : cause.detail + : outcome === "in_progress" + ? "The task is still running. Buzz does not have a final cause yet." + : "Buzz does not have enough evidence to explain the outcome yet."; + const confidence = + cause?.confidence === "direct" && gaps.length === 0 + ? "high" + : cause && cause.confidence !== "unknown" + ? "medium" + : "low"; + + return { + task: "Task description unavailable from current session evidence", + outcome, + why, + confidence, + evidence: facts.slice(-5), + unknowns: gaps, + nextAction: deniedDecision + ? "Review the denied tool request and grant only the access the task requires." + : latestError + ? "Open the failed event below and address its diagnostic before retrying." + : strongestFinding + ? "Review the highest-confidence finding and its linked evidence before retrying." + : "Collect the missing outcome evidence before choosing a fix.", + }; +} From d3cf0a3c9290d87d6b276c51bfedd6ef225d851a Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Wed, 5 Aug 2026 14:20:05 -0400 Subject: [PATCH 03/14] feat(desktop): verify causal remediation replays Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../agents/ui/CausalSessionTimeline.tsx | 22 +++ .../ui/trustworthySessionTimeline.test.mjs | 158 ++++++++++++++++++ .../agents/ui/trustworthySessionTimeline.ts | 144 +++++++++++++++- 3 files changed, 315 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/agents/ui/CausalSessionTimeline.tsx b/desktop/src/features/agents/ui/CausalSessionTimeline.tsx index 755745a49b..00bbb9103e 100644 --- a/desktop/src/features/agents/ui/CausalSessionTimeline.tsx +++ b/desktop/src/features/agents/ui/CausalSessionTimeline.tsx @@ -164,6 +164,28 @@ export function CausalSessionTimeline({
+ {explanation.remediation ? ( +
+
+

+ Controlled replay +

+ + {explanation.remediation.status.replace("_", " ")} + +
+

+ {explanation.remediation.remedy} +

+

+ Changed: {explanation.remediation.controlledChange} +

+

+ Result: {explanation.remediation.result} +

+
+ ) : null} +

Activity and raw telemetry continue below as supporting detail.

diff --git a/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs b/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs index 35a3696747..dfb4786fcf 100644 --- a/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs +++ b/desktop/src/features/agents/ui/trustworthySessionTimeline.test.mjs @@ -117,3 +117,161 @@ test("does not invent a cause or fix when evidence is incomplete", () => { assert.equal(explanation.confidence, "low"); assert.match(explanation.nextAction, /missing outcome evidence/i); }); + +test("models the real host-write incident and validates one controlled replay", () => { + const failedRun = [ + { + ...base, + seq: 1, + timestamp: "2026-08-05T12:00:00Z", + kind: "task_captured", + payload: { + description: + "Write the requested file and ask before changing the workspace", + sourceMessageId: "host-write-request", + }, + }, + { + ...base, + seq: 2, + timestamp: "2026-08-05T12:00:01Z", + kind: "host_operation", + payload: { + operation: "Wrote", + target: "workspace file", + executionPath: "host tool path outside ACP", + observedBy: "Codex host", + }, + }, + { + ...base, + seq: 3, + timestamp: "2026-08-05T12:00:02Z", + kind: "causal_hypothesis", + payload: { + title: "Host write bypassed the ACP permission gate", + summary: + "No permission appeared because the host tool performed the write outside the ACP execution path.", + confidence: "high", + evidenceIds: ["observer:session-1:2"], + }, + }, + { + ...base, + seq: 4, + timestamp: "2026-08-05T12:00:03Z", + kind: "turn_completed", + payload: {}, + }, + { + ...base, + seq: 5, + timestamp: "2026-08-05T12:00:04Z", + kind: "remediation_proposed", + payload: { + summary: "Route workspace writes through the governed ACP tool path", + controlledChange: "execution path: host tool → ACP workspace tool", + }, + }, + ]; + + const beforeReplay = explainSession(failedRun, []); + assert.equal( + beforeReplay.task, + "Write the requested file and ask before changing the workspace", + ); + assert.match(beforeReplay.why, /outside the ACP execution path/i); + assert.equal(beforeReplay.remediation.status, "not_tested"); + assert.match(beforeReplay.nextAction, /host tool → ACP workspace tool/i); + assert.equal( + beforeReplay.unknowns.some((gap) => gap.sourceLayer === "host_workspace"), + false, + ); + assert.equal( + beforeReplay.unknowns.some((gap) => gap.sourceLayer === "os_sandbox"), + true, + ); + + const afterReplay = explainSession( + [ + ...failedRun, + { + ...base, + sessionId: "session-replay-1", + turnId: "turn-replay-1", + seq: 6, + timestamp: "2026-08-05T12:01:00Z", + kind: "permission_decision", + payload: { mode: "ask", decision: "allow_once" }, + }, + { + ...base, + sessionId: "session-replay-1", + turnId: "turn-replay-1", + seq: 7, + timestamp: "2026-08-05T12:01:01Z", + kind: "replay_result", + payload: { + outcome: "succeeded", + expectedCauseObserved: true, + validatesRemedyId: "observer:session-1:5", + summary: + "The governed path requested permission before the write and the task succeeded.", + }, + }, + ], + [], + ); + + assert.equal(afterReplay.remediation.status, "validated"); + assert.match(afterReplay.remediation.result, /requested permission/i); + assert.match(afterReplay.nextAction, /validated governed execution path/i); +}); + +test("does not validate a remedy from an unrelated or permission-free replay", () => { + const failedRun = [ + { + ...base, + seq: 1, + kind: "remediation_proposed", + payload: { + summary: "Route writes through ACP", + controlledChange: "host tool → ACP workspace tool", + }, + }, + ]; + const successfulButUnlinkedReplay = { + ...base, + sessionId: "session-replay-2", + turnId: "turn-replay-2", + seq: 2, + kind: "replay_result", + payload: { + outcome: "succeeded", + expectedCauseObserved: true, + validatesRemedyId: "observer:some-other-session:1", + summary: "An unrelated replay succeeded.", + }, + }; + + const unlinked = explainSession( + [...failedRun, successfulButUnlinkedReplay], + [], + ); + assert.equal(unlinked.remediation.status, "not_tested"); + + const linkedWithoutPermission = explainSession( + [ + ...failedRun, + { + ...successfulButUnlinkedReplay, + payload: { + ...successfulButUnlinkedReplay.payload, + validatesRemedyId: "observer:session-1:1", + }, + }, + ], + [], + ); + assert.equal(linkedWithoutPermission.remediation.status, "inconclusive"); +}); diff --git a/desktop/src/features/agents/ui/trustworthySessionTimeline.ts b/desktop/src/features/agents/ui/trustworthySessionTimeline.ts index 0dfd8739ed..6eeb7516c0 100644 --- a/desktop/src/features/agents/ui/trustworthySessionTimeline.ts +++ b/desktop/src/features/agents/ui/trustworthySessionTimeline.ts @@ -30,6 +30,14 @@ export type CausalTimelineEvent = { export type SessionOutcome = "succeeded" | "failed" | "in_progress" | "unknown"; +export type RemediationVerification = { + remedy: string; + controlledChange: string; + status: "validated" | "rejected" | "inconclusive" | "not_tested"; + result: string; + evidenceIds: string[]; +}; + export type SessionExplanation = { task: string; outcome: SessionOutcome; @@ -38,6 +46,7 @@ export type SessionExplanation = { evidence: CausalTimelineEvent[]; unknowns: CausalTimelineEvent[]; nextAction: string; + remediation: RemediationVerification | null; }; const REQUIRED_SOURCE_LAYERS = [ @@ -55,6 +64,12 @@ function stringValue(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function stringValues(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + function observerEventId(event: ObserverEvent): string { return `observer:${event.sessionId ?? "unknown"}:${event.seq}`; } @@ -63,6 +78,46 @@ function projectObserverEvent(event: ObserverEvent): CausalTimelineEvent { const payload = objectPayload(event); const eventId = observerEventId(event); + if (event.kind === "host_operation") { + const operation = stringValue(payload.operation) ?? "Host operation"; + const target = stringValue(payload.target) ?? "unknown target"; + const path = stringValue(payload.executionPath) ?? "host runtime"; + return { + id: eventId, + timestamp: event.timestamp, + class: "fact", + title: `${operation} ${target}`, + detail: `The ${path} performed this operation.`, + sourceSystem: stringValue(payload.observedBy) ?? "Host runtime", + sourceLayer: "host_workspace", + observerRole: "observed", + confidence: "direct", + sessionId: event.sessionId, + turnId: event.turnId, + evidenceIds: [eventId], + }; + } + + if (event.kind === "causal_hypothesis") { + return { + id: eventId, + timestamp: event.timestamp, + class: "inference", + title: stringValue(payload.title) ?? "Causal hypothesis", + detail: + stringValue(payload.summary) ?? + "Buzz does not have a summary for this hypothesis.", + sourceSystem: "Buzz causal graph", + sourceLayer: "causal_correlation", + observerRole: "inferred", + confidence: + stringValue(payload.confidence) === "high" ? "correlated" : "unknown", + sessionId: event.sessionId, + turnId: event.turnId, + evidenceIds: stringValues(payload.evidenceIds), + }; + } + if (event.kind === "permission_decision") { const decision = stringValue(payload.decision) ?? "unknown"; const mode = stringValue(payload.mode) ?? "unspecified policy"; @@ -204,6 +259,9 @@ export function explainSession( (event) => event.class === "inference" && event.confidence === "correlated", ); + const causalHypothesis = [...timeline] + .reverse() + .find((event) => event.sourceLayer === "causal_correlation"); const deniedDecision = [...timeline] .reverse() .find( @@ -220,7 +278,12 @@ export function explainSession( : latestStarted ? "in_progress" : "unknown"; - const cause = strongestFinding ?? latestError ?? deniedDecision ?? null; + const cause = + causalHypothesis ?? + strongestFinding ?? + latestError ?? + deniedDecision ?? + null; const why = cause ? cause.detail === "Captured by the Buzz ACP observer." ? cause.title @@ -235,19 +298,82 @@ export function explainSession( ? "medium" : "low"; + const taskEvent = [...observerEvents] + .reverse() + .find((event) => event.kind === "task_captured"); + const taskPayload = taskEvent ? objectPayload(taskEvent) : {}; + const task = + stringValue(taskPayload.description) ?? + (stringValue(taskPayload.sourceMessageId) + ? `Task captured from message ${stringValue(taskPayload.sourceMessageId)}` + : "Task description unavailable from current session evidence"); + const remedyEvent = [...observerEvents] + .reverse() + .find((event) => event.kind === "remediation_proposed"); + const replayEvent = [...observerEvents] + .reverse() + .find((event) => event.kind === "replay_result"); + const remedyPayload = remedyEvent ? objectPayload(remedyEvent) : {}; + const replayPayload = replayEvent ? objectPayload(replayEvent) : {}; + const replayOutcome = stringValue(replayPayload.outcome); + const expectedCauseObserved = replayPayload.expectedCauseObserved === true; + const remedyEventId = remedyEvent ? observerEventId(remedyEvent) : null; + const replayValidatesRemedy = + remedyEventId !== null && + stringValue(replayPayload.validatesRemedyId) === remedyEventId; + const replayRequestedPermission = replayEvent + ? observerEvents.some( + (event) => + event.kind === "permission_decision" && + event.sessionId === replayEvent.sessionId && + event.turnId === replayEvent.turnId && + event.timestamp <= replayEvent.timestamp, + ) + : false; + const remediation = remedyEvent + ? { + remedy: + stringValue(remedyPayload.summary) ?? + "Proposed remediation unavailable", + controlledChange: + stringValue(remedyPayload.controlledChange) ?? + "Controlled change unavailable", + status: + replayEvent && replayValidatesRemedy + ? replayOutcome === "succeeded" && + expectedCauseObserved && + replayRequestedPermission + ? ("validated" as const) + : replayOutcome === "failed" + ? ("rejected" as const) + : ("inconclusive" as const) + : ("not_tested" as const), + result: replayEvent + ? (stringValue(replayPayload.summary) ?? "Replay result unavailable") + : "No controlled replay has tested this remedy yet.", + evidenceIds: replayEvent ? [observerEventId(replayEvent)] : [], + } + : null; + return { - task: "Task description unavailable from current session evidence", + task, outcome, why, confidence, evidence: facts.slice(-5), unknowns: gaps, - nextAction: deniedDecision - ? "Review the denied tool request and grant only the access the task requires." - : latestError - ? "Open the failed event below and address its diagnostic before retrying." - : strongestFinding - ? "Review the highest-confidence finding and its linked evidence before retrying." - : "Collect the missing outcome evidence before choosing a fix.", + nextAction: + remediation?.status === "validated" + ? "Use the validated governed execution path for the next run." + : remediation?.status === "not_tested" + ? `Replay with one controlled change: ${remediation.controlledChange}` + : deniedDecision + ? "Review the denied tool request and grant only the access the task requires." + : latestError + ? "Open the failed event below and address its diagnostic before retrying." + : strongestFinding + ? "Review the highest-confidence finding and its linked evidence before retrying." + : "Collect the missing outcome evidence before choosing a fix.", + remediation, }; } From 60d96a9ee154e70dce6d2ec745d2ae18dfa915f9 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Wed, 5 Aug 2026 14:50:20 -0400 Subject: [PATCH 04/14] feat(desktop): add causal experiment ledger Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../features/agents/lib/causalLedger.test.mjs | 169 ++++++++++++++ .../src/features/agents/lib/causalLedger.ts | 211 ++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 desktop/src/features/agents/lib/causalLedger.test.mjs create mode 100644 desktop/src/features/agents/lib/causalLedger.ts diff --git a/desktop/src/features/agents/lib/causalLedger.test.mjs b/desktop/src/features/agents/lib/causalLedger.test.mjs new file mode 100644 index 0000000000..384aaf171b --- /dev/null +++ b/desktop/src/features/agents/lib/causalLedger.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CAUSAL_EXPERIMENT_SCHEMA, CausalLedger } from "./causalLedger.ts"; + +function experiment(id, overrides = {}) { + return { + schema: CAUSAL_EXPERIMENT_SCHEMA, + experimentId: id, + recordedAt: "2026-08-05T18:44:22Z", + task: { + description: "Build the Buzz Causal Ledger", + sourceMessageId: "c5b7084", + }, + execution: { + sessionId: `session-${id}`, + turnId: `turn-${id}`, + replayOf: null, + }, + failureFingerprint: "host-write-without-acp-permission/v1", + context: { + codeVersion: "2330ec7", + policyVersion: "workspace-write/v1", + modelVersion: "gpt-5", + toolVersion: "buzz-acp/v1", + environmentVersion: "macos-arm64", + }, + hypothesis: { + cause: "Host write bypassed ACP", + evidenceIds: [`evidence-${id}`], + }, + intervention: { + remedyId: "route-through-acp/v1", + changedVariable: "execution_path", + }, + result: { outcome: "validated", evidenceIds: [`result-${id}`] }, + coverage: { + acp: "observed", + host_workspace: "observed", + os_sandbox: "observed", + }, + relations: { supports: [], contradicts: [], invalidates: [] }, + ...overrides, + }; +} + +test("appends immutable, hash-linked causal experiments", async () => { + const ledger = new CausalLedger(); + const first = await ledger.append(experiment("one")); + const second = await ledger.append(experiment("two")); + + assert.equal(first.sequence, 1); + assert.equal(second.previousHash, first.hash); + assert.equal(await ledger.verify(), true); + await assert.rejects(() => ledger.append(experiment("one")), /Duplicate/); + assert.throws(() => { + first.experiment.result.outcome = "rejected"; + }, /read only/); +}); + +test("restores an append-only journal and rejects a tampered restart", async () => { + const ledger = new CausalLedger(); + await ledger.append(experiment("before-crash")); + await ledger.append(experiment("after-restart")); + const journal = ledger.toJournal(); + + const restored = await CausalLedger.fromJournal(journal); + assert.equal(restored.size, 2); + assert.equal(await restored.verify(), true); + + const tampered = journal.replace("Host write bypassed ACP", "Invented cause"); + await assert.rejects( + () => CausalLedger.fromJournal(tampered), + /integrity failure/, + ); +}); + +test("does not let unrelated or version-drifted successes strengthen a finding", async () => { + const ledger = new CausalLedger(); + const target = experiment("target"); + await ledger.append(target); + await ledger.append(experiment("same-context")); + await ledger.append( + experiment("unrelated", { failureFingerprint: "different-failure/v1" }), + ); + await ledger.append( + experiment("drifted", { + context: { ...target.context, policyVersion: "workspace-write/v2" }, + }), + ); + + const finding = ledger.findingFor(target); + assert.equal(finding.comparableExperiments, 2); + assert.equal(finding.validated, 2); + assert.equal(finding.confidence, 1); +}); + +test("missing coverage and contradictions lower confidence", async () => { + const ledger = new CausalLedger(); + const target = experiment("target"); + await ledger.append(target); + await ledger.append( + experiment("contradiction", { + result: { outcome: "rejected", evidenceIds: ["contradiction-evidence"] }, + relations: { supports: [], contradicts: ["target"], invalidates: [] }, + }), + ); + await ledger.append( + experiment("live-dogfood-gap", { + result: { outcome: "inconclusive", evidenceIds: [] }, + coverage: { + acp: "observed", + host_workspace: "missing", + os_sandbox: "missing", + }, + }), + ); + + const finding = ledger.findingFor(target); + assert.deepEqual( + { + validated: finding.validated, + rejected: finding.rejected, + inconclusive: finding.inconclusive, + }, + { validated: 1, rejected: 1, inconclusive: 1 }, + ); + assert.equal(finding.confidence, 1 / 3); +}); + +test("keeps 10,000 adversarial experiments inside their causal boundaries", async () => { + const ledger = new CausalLedger(); + const target = experiment("target"); + await ledger.append(target); + for (let index = 1; index < 10_000; index += 1) { + const poisoned = index % 5 === 0; + const versionDrift = index % 7 === 0; + await ledger.append( + experiment(`scale-${index}`, { + failureFingerprint: poisoned + ? "poisoned-unrelated/v1" + : target.failureFingerprint, + context: versionDrift + ? { ...target.context, codeVersion: `drift-${index}` } + : target.context, + result: { + outcome: index % 11 === 0 ? "rejected" : "validated", + evidenceIds: [`result-${index}`], + }, + }), + ); + } + + const finding = ledger.findingFor(target); + assert.equal(ledger.size, 10_000); + assert.equal(await ledger.verify(), true); + assert.equal( + finding.comparableExperiments, + ledger + .entries() + .filter( + ({ experiment: item }) => + item.failureFingerprint === target.failureFingerprint && + item.context.codeVersion === target.context.codeVersion, + ).length, + ); + assert.equal(finding.experimentIds.includes("scale-5"), false); + assert.equal(finding.experimentIds.includes("scale-7"), false); +}); diff --git a/desktop/src/features/agents/lib/causalLedger.ts b/desktop/src/features/agents/lib/causalLedger.ts new file mode 100644 index 0000000000..f285fcd7a8 --- /dev/null +++ b/desktop/src/features/agents/lib/causalLedger.ts @@ -0,0 +1,211 @@ +export const CAUSAL_EXPERIMENT_SCHEMA = "causal-experiment/v1" as const; + +export type ExperimentOutcome = + | "validated" + | "rejected" + | "inconclusive" + | "untested"; + +export type EvidenceCoverage = "observed" | "missing"; + +export type CausalExperiment = { + schema: typeof CAUSAL_EXPERIMENT_SCHEMA; + experimentId: string; + recordedAt: string; + task: { description: string; sourceMessageId: string | null }; + execution: { sessionId: string; turnId: string; replayOf: string | null }; + failureFingerprint: string; + context: { + codeVersion: string; + policyVersion: string; + modelVersion: string; + toolVersion: string; + environmentVersion: string; + }; + hypothesis: { cause: string; evidenceIds: string[] }; + intervention: { remedyId: string; changedVariable: string }; + result: { outcome: ExperimentOutcome; evidenceIds: string[] }; + coverage: Record; + relations: { + supports: string[]; + contradicts: string[]; + invalidates: string[]; + }; +}; + +export type LedgerEntry = { + sequence: number; + previousHash: string; + hash: string; + experiment: CausalExperiment; +}; + +export type CausalFinding = { + comparableExperiments: number; + validated: number; + rejected: number; + inconclusive: number; + confidence: number; + experimentIds: string[]; +}; + +const GENESIS_HASH = "0".repeat(64); + +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +async function sha256(value: string): Promise { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +function sameContext( + left: CausalExperiment["context"], + right: CausalExperiment["context"], +): boolean { + return canonical(left) === canonical(right); +} + +function hasCompleteCoverage(experiment: CausalExperiment): boolean { + return Object.values(experiment.coverage).every( + (value) => value === "observed", + ); +} + +function immutableClone(value: T): T { + if (Array.isArray(value)) { + return Object.freeze(value.map(immutableClone)) as T; + } + if (value && typeof value === "object") { + const clone = Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + immutableClone(entry), + ]), + ); + return Object.freeze(clone) as T; + } + return value; +} + +export class CausalLedger { + readonly #entries: LedgerEntry[] = []; + readonly #ids = new Set(); + + get size(): number { + return this.#entries.length; + } + + entries(): readonly LedgerEntry[] { + return this.#entries; + } + + toJournal(): string { + return this.#entries.map((entry) => canonical(entry)).join("\n"); + } + + static async fromJournal(journal: string): Promise { + const ledger = new CausalLedger(); + const lines = journal.split("\n").filter((line) => line.trim()); + for (const line of lines) { + const persisted = JSON.parse(line) as LedgerEntry; + const restored = await ledger.append(persisted.experiment); + if ( + restored.sequence !== persisted.sequence || + restored.previousHash !== persisted.previousHash || + restored.hash !== persisted.hash + ) { + throw new Error( + `Causal ledger integrity failure at sequence ${persisted.sequence}`, + ); + } + } + return ledger; + } + + async append(experiment: CausalExperiment): Promise { + if (experiment.schema !== CAUSAL_EXPERIMENT_SCHEMA) { + throw new Error( + `Unsupported causal experiment schema: ${experiment.schema}`, + ); + } + if (this.#ids.has(experiment.experimentId)) { + throw new Error( + `Duplicate causal experiment: ${experiment.experimentId}`, + ); + } + const sequence = this.#entries.length + 1; + const previousHash = this.#entries.at(-1)?.hash ?? GENESIS_HASH; + const frozenExperiment = immutableClone(experiment); + const hash = await sha256( + canonical({ sequence, previousHash, experiment: frozenExperiment }), + ); + const entry = Object.freeze({ + sequence, + previousHash, + hash, + experiment: frozenExperiment, + }); + this.#entries.push(entry); + this.#ids.add(experiment.experimentId); + return entry; + } + + async verify(): Promise { + let previousHash = GENESIS_HASH; + for (const entry of this.#entries) { + if (entry.previousHash !== previousHash) return false; + const expected = await sha256( + canonical({ + sequence: entry.sequence, + previousHash: entry.previousHash, + experiment: entry.experiment, + }), + ); + if (entry.hash !== expected) return false; + previousHash = entry.hash; + } + return true; + } + + findingFor(target: CausalExperiment): CausalFinding { + const comparable = this.#entries + .map((entry) => entry.experiment) + .filter( + (experiment) => + experiment.failureFingerprint === target.failureFingerprint && + experiment.intervention.remedyId === target.intervention.remedyId && + sameContext(experiment.context, target.context), + ); + const validated = comparable.filter( + (experiment) => experiment.result.outcome === "validated", + ).length; + const rejected = comparable.filter( + (experiment) => experiment.result.outcome === "rejected", + ).length; + const inconclusive = comparable.length - validated - rejected; + const complete = comparable.filter(hasCompleteCoverage).length; + const decisive = validated + rejected; + const evidenceFactor = comparable.length ? complete / comparable.length : 0; + const confidence = decisive ? (validated / decisive) * evidenceFactor : 0; + return { + comparableExperiments: comparable.length, + validated, + rejected, + inconclusive, + confidence, + experimentIds: comparable.map((experiment) => experiment.experimentId), + }; + } +} From 03482952e78bb0285cb19b1b5f03bc7c3f2a6c38 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Wed, 5 Aug 2026 14:56:52 -0400 Subject: [PATCH 05/14] feat(desktop): ingest live causal candidates Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../agents/lib/liveCausalLedger.test.mjs | 62 ++++++++ .../features/agents/lib/liveCausalLedger.ts | 136 ++++++++++++++++++ .../src/features/agents/observerRelayStore.ts | 16 +++ .../agents/useAgentObserverIngestion.ts | 14 +- 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/agents/lib/liveCausalLedger.test.mjs create mode 100644 desktop/src/features/agents/lib/liveCausalLedger.ts diff --git a/desktop/src/features/agents/lib/liveCausalLedger.test.mjs b/desktop/src/features/agents/lib/liveCausalLedger.test.mjs new file mode 100644 index 0000000000..2ca3aafadd --- /dev/null +++ b/desktop/src/features/agents/lib/liveCausalLedger.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { LiveCausalLedger } from "./liveCausalLedger.ts"; + +function memoryStorage() { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }; +} + +function observer(seq, kind, payload = {}) { + return { + seq, + timestamp: `2026-08-05T18:5${seq}:00Z`, + kind, + agentIndex: 0, + channelId: "2db0f46d", + sessionId: "live-session-1", + turnId: "turn-1", + payload, + }; +} + +test("automatically closes a live session into an owner-local candidate", async () => { + const storage = memoryStorage(); + const ledger = new LiveCausalLedger("OWNER", storage); + await ledger.ingest( + "agent", + observer(1, "task_captured", { + description: "Wire this session into the causal ledger", + sourceMessageId: "414ccec", + }), + ); + await ledger.ingest( + "agent", + observer(2, "permission_decision", { + decision: "allow_once", + }), + ); + await ledger.ingest("agent", observer(3, "turn_completed")); + + const [entry] = await ledger.entries(); + assert.equal(entry.experiment.result.outcome, "untested"); + assert.equal(entry.experiment.task.sourceMessageId, "414ccec"); + assert.equal(entry.experiment.coverage.acp_permission_gate, "observed"); + assert.equal(entry.experiment.coverage.host_workspace, "missing"); + assert.equal(entry.experiment.coverage.os_sandbox, "missing"); + assert.ok(storage.getItem(ledger.storageKey)); +}); + +test("restores the candidate after restart and does not duplicate terminal replay", async () => { + const storage = memoryStorage(); + const first = new LiveCausalLedger("owner", storage); + await first.ingest("agent", observer(1, "turn_completed")); + + const restarted = new LiveCausalLedger("owner", storage); + await restarted.ingest("agent", observer(1, "turn_completed")); + assert.equal((await restarted.entries()).length, 1); +}); diff --git a/desktop/src/features/agents/lib/liveCausalLedger.ts b/desktop/src/features/agents/lib/liveCausalLedger.ts new file mode 100644 index 0000000000..54a68a395d --- /dev/null +++ b/desktop/src/features/agents/lib/liveCausalLedger.ts @@ -0,0 +1,136 @@ +import type { ObserverEvent } from "../ui/agentSessionTypes"; +import { + CAUSAL_EXPERIMENT_SCHEMA, + CausalLedger, + type CausalExperiment, +} from "./causalLedger"; + +type JournalStorage = Pick; + +function payload(event: ObserverEvent): Record { + return event.payload && typeof event.payload === "object" + ? (event.payload as Record) + : {}; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function eventId(event: ObserverEvent): string { + return `observer:${event.sessionId ?? "unknown"}:${event.seq}:${event.timestamp}`; +} + +export class LiveCausalLedger { + readonly #storage: JournalStorage; + readonly #owner: string; + readonly #sessions = new Map(); + #ledger = new CausalLedger(); + #ready: Promise; + #writes: Promise = Promise.resolve(); + + constructor(owner: string, storage: JournalStorage) { + this.#owner = owner.toLowerCase(); + this.#storage = storage; + this.#ready = this.#restore(); + } + + get storageKey(): string { + return `buzz-causal-ledger.v1:${this.#owner}`; + } + + async #restore() { + const journal = this.#storage.getItem(this.storageKey); + if (journal) this.#ledger = await CausalLedger.fromJournal(journal); + } + + ingest(agentPubkey: string, event: ObserverEvent): Promise { + this.#writes = this.#writes.then(async () => { + await this.#ready; + if (!event.sessionId) return; + const key = `${agentPubkey.toLowerCase()}:${event.sessionId}`; + const session = this.#sessions.get(key) ?? []; + session.push(event); + this.#sessions.set(key, session); + if (event.kind !== "turn_completed" && event.kind !== "turn_error") + return; + + const experimentId = `live:${agentPubkey.toLowerCase()}:${event.sessionId}`; + if ( + this.#ledger + .entries() + .some((entry) => entry.experiment.experimentId === experimentId) + ) { + this.#sessions.delete(key); + return; + } + await this.#ledger.append( + this.#candidate(experimentId, event.sessionId, event.turnId, session), + ); + this.#storage.setItem(this.storageKey, this.#ledger.toJournal()); + this.#sessions.delete(key); + }); + return this.#writes; + } + + async entries() { + await this.#ready; + await this.#writes; + return this.#ledger.entries(); + } + + #candidate( + experimentId: string, + sessionId: string, + turnId: string | null, + events: ObserverEvent[], + ): CausalExperiment { + const taskEvent = events.find((event) => event.kind === "task_captured"); + const taskPayload = taskEvent ? payload(taskEvent) : {}; + const layers = new Set( + events.map((event) => { + if (event.kind === "host_operation") return "host_workspace"; + if (event.kind === "permission_decision") return "acp_permission_gate"; + return "acp_observer"; + }), + ); + return { + schema: CAUSAL_EXPERIMENT_SCHEMA, + experimentId, + recordedAt: events.at(-1)?.timestamp ?? new Date().toISOString(), + task: { + description: + text(taskPayload.description) ?? + "Task unavailable from observer evidence", + sourceMessageId: text(taskPayload.sourceMessageId), + }, + execution: { sessionId, turnId: turnId ?? "unknown", replayOf: null }, + failureFingerprint: "unclassified-live-candidate/v1", + context: { + codeVersion: "unknown", + policyVersion: "unknown", + modelVersion: "unknown", + toolVersion: "buzz-acp-observer/v1", + environmentVersion: "unknown", + }, + hypothesis: { cause: "unclassified", evidenceIds: [] }, + intervention: { + remedyId: "unclassified", + changedVariable: "unclassified", + }, + result: { + outcome: "untested", + evidenceIds: events.map(eventId), + }, + coverage: { + acp_observer: layers.has("acp_observer") ? "observed" : "missing", + acp_permission_gate: layers.has("acp_permission_gate") + ? "observed" + : "missing", + host_workspace: layers.has("host_workspace") ? "observed" : "missing", + os_sandbox: "missing", + }, + relations: { supports: [], contradicts: [], invalidates: [] }, + }; + } +} diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 611fdd489d..2ef9b891f7 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -45,6 +45,9 @@ const EMPTY_EVENTS: ObserverEvent[] = []; const EMPTY_TRANSCRIPT: TranscriptItem[] = []; const listeners = new Set<() => void>(); +const eventListeners = new Set< + (agentPubkey: string, event: ObserverEvent) => void +>(); const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); @@ -211,6 +214,9 @@ function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { ? sorted.slice(sorted.length - MAX_OBSERVER_EVENTS) : sorted; eventsByAgent.set(key, final); + for (const listener of eventListeners) { + listener(key, event); + } // Determine whether the new event landed at the end of the sorted array. // If it did (common case), we can incrementally process just this event. @@ -233,6 +239,16 @@ function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { notifyListeners(); } +/** Subscribe to each newly accepted live observer event after deduplication. */ +export function subscribeObserverEvents( + listener: (agentPubkey: string, event: ObserverEvent) => void, +) { + eventListeners.add(listener); + return () => { + eventListeners.delete(listener); + }; +} + /** * Compose the map key for the channel-scoped archive transcript. * Separates agent identity from channel with `:` — the same delimiter used by diff --git a/desktop/src/features/agents/useAgentObserverIngestion.ts b/desktop/src/features/agents/useAgentObserverIngestion.ts index 386b762142..13320d0ffb 100644 --- a/desktop/src/features/agents/useAgentObserverIngestion.ts +++ b/desktop/src/features/agents/useAgentObserverIngestion.ts @@ -5,7 +5,11 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; -import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; +import { + subscribeObserverEvents, + useManagedAgentObserverBridge, +} from "@/features/agents/observerRelayStore"; +import { LiveCausalLedger } from "@/features/agents/lib/liveCausalLedger"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ManagedAgent } from "@/shared/api/types"; @@ -113,4 +117,12 @@ export function useAgentObserverIngestion() { useManagedAgentObserverBridge(ingestionAgents); useActiveAgentTurnsBridge(ingestionAgents); + + React.useEffect(() => { + if (!currentPubkey) return; + const ledger = new LiveCausalLedger(currentPubkey, window.localStorage); + return subscribeObserverEvents((agentPubkey, event) => { + void ledger.ingest(agentPubkey, event); + }); + }, [currentPubkey]); } From bcf1d125dcfa6a3449b47505d516d35dea893ec4 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Wed, 5 Aug 2026 15:26:31 -0400 Subject: [PATCH 06/14] feat(desktop): persist causal ledger in sqlite Store immutable owner-scoped entries transactionally and restore the verified chain after restart. Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src-tauri/src/archive/causal_ledger.rs | 171 ++++++++++++++++++ desktop/src-tauri/src/archive/mod.rs | 1 + desktop/src-tauri/src/archive/store.rs | 14 ++ desktop/src-tauri/src/lib.rs | 9 +- .../agents/lib/liveCausalLedger.test.mjs | 20 +- .../features/agents/lib/liveCausalLedger.ts | 38 +++- .../agents/useAgentObserverIngestion.ts | 6 +- desktop/src/shared/api/tauriCausalLedger.ts | 17 ++ 8 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 desktop/src-tauri/src/archive/causal_ledger.rs create mode 100644 desktop/src/shared/api/tauriCausalLedger.ts diff --git a/desktop/src-tauri/src/archive/causal_ledger.rs b/desktop/src-tauri/src/archive/causal_ledger.rs new file mode 100644 index 0000000000..96fd7f8032 --- /dev/null +++ b/desktop/src-tauri/src/archive/causal_ledger.rs @@ -0,0 +1,171 @@ +use rusqlite::{params, Connection, OptionalExtension}; +use serde::Deserialize; +use tauri::State; + +use crate::app_state::AppState; + +use super::{identity_pubkey, now_secs, run_archive_db_task}; + +const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LedgerEntryEnvelope { + sequence: u64, + previous_hash: String, + hash: String, + experiment: ExperimentIdentity, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExperimentIdentity { + experiment_id: String, +} + +/// Return the current owner's immutable causal-ledger journal in chain order. +#[tauri::command] +pub async fn read_causal_ledger(state: State<'_, AppState>) -> Result, String> { + let identity = identity_pubkey(&state)?; + run_archive_db_task(move |conn| read_entries(conn, &identity)).await +} + +fn read_entries(conn: &Connection, identity: &str) -> Result, String> { + let mut statement = conn + .prepare( + "SELECT entry_json FROM causal_ledger_entries + WHERE identity_pubkey = ?1 ORDER BY sequence ASC", + ) + .map_err(|error| format!("failed to prepare causal ledger read: {error}"))?; + let rows = statement + .query_map(params![identity], |row| row.get::<_, String>(0)) + .map_err(|error| format!("failed to read causal ledger: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("failed to decode causal ledger row: {error}")) +} + +/// Transactionally append one owner-scoped hash-linked causal-ledger entry. +#[tauri::command] +pub async fn append_causal_ledger_entry( + state: State<'_, AppState>, + entry_json: String, +) -> Result<(), String> { + let identity = identity_pubkey(&state)?; + let entry: LedgerEntryEnvelope = serde_json::from_str(&entry_json) + .map_err(|error| format!("invalid causal ledger entry: {error}"))?; + if entry.sequence == 0 || entry.hash.len() != 64 || entry.previous_hash.len() != 64 { + return Err("invalid causal ledger chain fields".to_string()); + } + let recorded_at = now_secs(); + run_archive_db_task(move |conn| append_entry(conn, &identity, &entry_json, entry, recorded_at)) + .await +} + +fn append_entry( + conn: &Connection, + identity: &str, + entry_json: &str, + entry: LedgerEntryEnvelope, + recorded_at: i64, +) -> Result<(), String> { + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(|error| format!("failed to begin causal ledger append: {error}"))?; + let result = (|| { + let tail = conn + .query_row( + "SELECT sequence, hash FROM causal_ledger_entries + WHERE identity_pubkey = ?1 ORDER BY sequence DESC LIMIT 1", + params![identity], + |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|error| format!("failed to read causal ledger tail: {error}"))?; + let expected_sequence = tail.as_ref().map_or(1, |(sequence, _)| sequence + 1); + let expected_previous = tail + .as_ref() + .map_or(GENESIS_HASH, |(_, hash)| hash.as_str()); + + if entry.sequence != expected_sequence || entry.previous_hash != expected_previous { + return Err(format!( + "causal ledger append conflict: expected sequence {expected_sequence}" + )); + } + conn + .execute( + "INSERT INTO causal_ledger_entries + (identity_pubkey, sequence, experiment_id, previous_hash, hash, entry_json, recorded_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + identity, + entry.sequence, + entry.experiment.experiment_id, + entry.previous_hash, + entry.hash, + entry_json, + recorded_at + ], + ) + .map_err(|error| format!("failed to append causal ledger entry: {error}"))?; + Ok(()) + })(); + match result { + Ok(()) => conn + .execute_batch("COMMIT") + .map_err(|error| format!("failed to commit causal ledger append: {error}")), + Err(error) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::archive::store::open_archive_db; + + fn envelope(sequence: u64, previous_hash: &str) -> (String, LedgerEntryEnvelope) { + let hash = format!("{sequence:064x}"); + let experiment_id = format!("experiment-{sequence}"); + let json = serde_json::json!({ + "sequence": sequence, + "previousHash": previous_hash, + "hash": hash, + "experiment": { "experimentId": experiment_id } + }) + .to_string(); + let parsed = serde_json::from_str(&json).expect("test envelope should decode"); + (json, parsed) + } + + #[test] + fn persists_ten_thousand_owner_scoped_entries_on_disk_and_reopens() { + let directory = tempfile::tempdir().expect("temp directory"); + let path = directory.path().join("archive.db"); + let conn = open_archive_db(&path).expect("open archive database"); + let mut previous = GENESIS_HASH.to_string(); + for sequence in 1..=10_000 { + let (json, entry) = envelope(sequence, &previous); + previous = entry.hash.clone(); + append_entry(&conn, "owner-a", &json, entry, 0).expect("append entry"); + } + drop(conn); + + let reopened = open_archive_db(&path).expect("reopen archive database"); + assert_eq!( + read_entries(&reopened, "owner-a").expect("read").len(), + 10_000 + ); + assert!(read_entries(&reopened, "owner-b").expect("read").is_empty()); + } + + #[test] + fn rejects_a_non_contiguous_chain_without_writing_it() { + let conn = Connection::open_in_memory().expect("open in-memory database"); + conn.execute_batch(crate::archive::store::SCHEMA) + .expect("initialize schema"); + let (json, entry) = envelope(2, GENESIS_HASH); + assert!(append_entry(&conn, "owner", &json, entry, 0).is_err()); + assert!(read_entries(&conn, "owner").expect("read").is_empty()); + } +} diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 42c6812674..3c0dec414a 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -17,6 +17,7 @@ //! validation (sig/id + kind + p-tag + agent tag + frame=telemetry + author //! == agent) is applied fail-closed. +pub(crate) mod causal_ledger; mod pipeline; pub mod store; diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs index ae0ef92e4b..935baa6a12 100644 --- a/desktop/src-tauri/src/archive/store.rs +++ b/desktop/src-tauri/src/archive/store.rs @@ -75,6 +75,20 @@ CREATE TABLE IF NOT EXISTS archive_migrations ( name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS causal_ledger_entries ( + identity_pubkey TEXT NOT NULL, + sequence INTEGER NOT NULL, + experiment_id TEXT NOT NULL, + previous_hash TEXT NOT NULL, + hash TEXT NOT NULL, + entry_json TEXT NOT NULL, + recorded_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, sequence), + UNIQUE (identity_pubkey, experiment_id) +); +CREATE INDEX IF NOT EXISTS idx_causal_ledger_experiment + ON causal_ledger_entries (identity_pubkey, experiment_id); "; // ── Open / init ───────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2847b87877..0adc3b2f0c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -85,11 +85,8 @@ use tray_menu::show_main_window; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // mesh-llm's async chains (model download, node start/join) overflow - // tokio's default 2 MiB worker stacks — a stack-guard SIGABRT, not a - // panic. Upstream mesh-llm and mesh-console both run on 8 MiB worker - // stacks for this reason; give Tauri's command runtime the same headroom - // before anything else touches tauri::async_runtime. + // mesh-llm's async chains overflow tokio's default 2 MiB worker stacks. + // Match upstream's 8 MiB stacks before anything touches tauri::async_runtime. #[cfg(feature = "mesh-llm")] match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -903,6 +900,8 @@ pub fn run() { archive::read_archived_observer_events_for_channel, archive::index_observer_channel_id, archive::read_unindexed_observer_rows, + archive::causal_ledger::read_causal_ledger, + archive::causal_ledger::append_causal_ledger_entry, is_auto_update_supported, set_window_vibrancy, #[cfg(target_os = "macos")] diff --git a/desktop/src/features/agents/lib/liveCausalLedger.test.mjs b/desktop/src/features/agents/lib/liveCausalLedger.test.mjs index 2ca3aafadd..ea3bc06ebe 100644 --- a/desktop/src/features/agents/lib/liveCausalLedger.test.mjs +++ b/desktop/src/features/agents/lib/liveCausalLedger.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { LiveCausalLedger } from "./liveCausalLedger.ts"; +import { + browserLedgerPersistence, + LiveCausalLedger, +} from "./liveCausalLedger.ts"; function memoryStorage() { const values = new Map(); @@ -26,7 +29,10 @@ function observer(seq, kind, payload = {}) { test("automatically closes a live session into an owner-local candidate", async () => { const storage = memoryStorage(); - const ledger = new LiveCausalLedger("OWNER", storage); + const ledger = new LiveCausalLedger( + "OWNER", + browserLedgerPersistence("OWNER", storage), + ); await ledger.ingest( "agent", observer(1, "task_captured", { @@ -53,10 +59,16 @@ test("automatically closes a live session into an owner-local candidate", async test("restores the candidate after restart and does not duplicate terminal replay", async () => { const storage = memoryStorage(); - const first = new LiveCausalLedger("owner", storage); + const first = new LiveCausalLedger( + "owner", + browserLedgerPersistence("owner", storage), + ); await first.ingest("agent", observer(1, "turn_completed")); - const restarted = new LiveCausalLedger("owner", storage); + const restarted = new LiveCausalLedger( + "owner", + browserLedgerPersistence("owner", storage), + ); await restarted.ingest("agent", observer(1, "turn_completed")); assert.equal((await restarted.entries()).length, 1); }); diff --git a/desktop/src/features/agents/lib/liveCausalLedger.ts b/desktop/src/features/agents/lib/liveCausalLedger.ts index 54a68a395d..de2c896caa 100644 --- a/desktop/src/features/agents/lib/liveCausalLedger.ts +++ b/desktop/src/features/agents/lib/liveCausalLedger.ts @@ -5,7 +5,31 @@ import { type CausalExperiment, } from "./causalLedger"; -type JournalStorage = Pick; +export type CausalLedgerPersistence = { + loadJournal(): Promise; + appendEntry(entry: import("./causalLedger").LedgerEntry): Promise; +}; + +export function browserLedgerPersistence( + owner: string, + storage: Pick, +): CausalLedgerPersistence { + const storageKey = `buzz-causal-ledger.v1:${owner.toLowerCase()}`; + return { + async loadJournal() { + return storage.getItem(storageKey) ?? ""; + }, + async appendEntry(entry) { + const existing = storage.getItem(storageKey); + storage.setItem( + storageKey, + existing + ? `${existing}\n${JSON.stringify(entry)}` + : JSON.stringify(entry), + ); + }, + }; +} function payload(event: ObserverEvent): Record { return event.payload && typeof event.payload === "object" @@ -22,16 +46,16 @@ function eventId(event: ObserverEvent): string { } export class LiveCausalLedger { - readonly #storage: JournalStorage; + readonly #persistence: CausalLedgerPersistence; readonly #owner: string; readonly #sessions = new Map(); #ledger = new CausalLedger(); #ready: Promise; #writes: Promise = Promise.resolve(); - constructor(owner: string, storage: JournalStorage) { + constructor(owner: string, persistence: CausalLedgerPersistence) { this.#owner = owner.toLowerCase(); - this.#storage = storage; + this.#persistence = persistence; this.#ready = this.#restore(); } @@ -40,7 +64,7 @@ export class LiveCausalLedger { } async #restore() { - const journal = this.#storage.getItem(this.storageKey); + const journal = await this.#persistence.loadJournal(); if (journal) this.#ledger = await CausalLedger.fromJournal(journal); } @@ -64,10 +88,10 @@ export class LiveCausalLedger { this.#sessions.delete(key); return; } - await this.#ledger.append( + const entry = await this.#ledger.append( this.#candidate(experimentId, event.sessionId, event.turnId, session), ); - this.#storage.setItem(this.storageKey, this.#ledger.toJournal()); + await this.#persistence.appendEntry(entry); this.#sessions.delete(key); }); return this.#writes; diff --git a/desktop/src/features/agents/useAgentObserverIngestion.ts b/desktop/src/features/agents/useAgentObserverIngestion.ts index 13320d0ffb..b49f5b20d5 100644 --- a/desktop/src/features/agents/useAgentObserverIngestion.ts +++ b/desktop/src/features/agents/useAgentObserverIngestion.ts @@ -10,6 +10,7 @@ import { useManagedAgentObserverBridge, } from "@/features/agents/observerRelayStore"; import { LiveCausalLedger } from "@/features/agents/lib/liveCausalLedger"; +import { createTauriCausalLedgerPersistence } from "@/shared/api/tauriCausalLedger"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ManagedAgent } from "@/shared/api/types"; @@ -120,7 +121,10 @@ export function useAgentObserverIngestion() { React.useEffect(() => { if (!currentPubkey) return; - const ledger = new LiveCausalLedger(currentPubkey, window.localStorage); + const ledger = new LiveCausalLedger( + currentPubkey, + createTauriCausalLedgerPersistence(), + ); return subscribeObserverEvents((agentPubkey, event) => { void ledger.ingest(agentPubkey, event); }); diff --git a/desktop/src/shared/api/tauriCausalLedger.ts b/desktop/src/shared/api/tauriCausalLedger.ts new file mode 100644 index 0000000000..3963baba74 --- /dev/null +++ b/desktop/src/shared/api/tauriCausalLedger.ts @@ -0,0 +1,17 @@ +import type { LedgerEntry } from "@/features/agents/lib/causalLedger"; +import type { CausalLedgerPersistence } from "@/features/agents/lib/liveCausalLedger"; +import { invokeTauri } from "./tauri"; + +export function createTauriCausalLedgerPersistence(): CausalLedgerPersistence { + return { + async loadJournal() { + const entries = await invokeTauri("read_causal_ledger"); + return entries.join("\n"); + }, + async appendEntry(entry: LedgerEntry) { + await invokeTauri("append_causal_ledger_entry", { + entryJson: JSON.stringify(entry), + }); + }, + }; +} From a0fca2790de43db10405308b31216e2f5a92db35 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Wed, 5 Aug 2026 15:31:01 -0400 Subject: [PATCH 07/14] feat(desktop): inspect causal ledger candidates Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../agents/ui/CausalCandidateCard.tsx | 117 ++++++++++++++++++ .../ui/causalCandidateInspection.test.mjs | 66 ++++++++++ .../agents/ui/causalCandidateInspection.ts | 84 +++++++++++++ .../channels/ui/AgentSessionThreadPanel.tsx | 8 ++ desktop/src/shared/api/tauriCausalLedger.ts | 9 +- 5 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/agents/ui/CausalCandidateCard.tsx create mode 100644 desktop/src/features/agents/ui/causalCandidateInspection.test.mjs create mode 100644 desktop/src/features/agents/ui/causalCandidateInspection.ts diff --git a/desktop/src/features/agents/ui/CausalCandidateCard.tsx b/desktop/src/features/agents/ui/CausalCandidateCard.tsx new file mode 100644 index 0000000000..1989a8f70b --- /dev/null +++ b/desktop/src/features/agents/ui/CausalCandidateCard.tsx @@ -0,0 +1,117 @@ +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, Eye, GitBranch, ShieldCheck } from "lucide-react"; + +import { readCausalLedger } from "@/shared/api/tauriCausalLedger"; +import { Badge } from "@/shared/ui/badge"; + +import { inspectCausalCandidate } from "./causalCandidateInspection"; + +export function CausalCandidateCard({ + sessionId, +}: { + sessionId: string | null; +}) { + const query = useQuery({ + queryKey: ["causal-ledger", sessionId], + queryFn: readCausalLedger, + enabled: Boolean(sessionId), + refetchInterval: 5_000, + }); + const inspection = inspectCausalCandidate(query.data ?? [], sessionId); + if (!inspection) return null; + + return ( +
+
+
+

+ Durable candidate +

+

{inspection.task}

+
+ + {inspection.status} + +
+ + + + + +
+
+ +
+

+ Promotion gate +

+

{inspection.nextGate}

+
+
+
+
+ ); +} + +function CandidateList({ + empty, + icon: Icon, + items, + label, + warning = false, +}: { + empty?: string; + icon: typeof Eye; + items: readonly string[]; + label: string; + warning?: boolean; +}) { + return ( +
+

+ {label} +

+ {items.length ? ( +
    + {items.map((item) => ( +
  • + + {item} +
  • + ))} +
+ ) : ( +

{empty}

+ )} +
+ ); +} diff --git a/desktop/src/features/agents/ui/causalCandidateInspection.test.mjs b/desktop/src/features/agents/ui/causalCandidateInspection.test.mjs new file mode 100644 index 0000000000..733bfaffb0 --- /dev/null +++ b/desktop/src/features/agents/ui/causalCandidateInspection.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { inspectCausalCandidate } from "./causalCandidateInspection.ts"; + +function entry(sessionId, overrides = {}) { + return { + sequence: 1, + previousHash: "0".repeat(64), + hash: "1".repeat(64), + experiment: { + schema: "causal-experiment/v1", + experimentId: `live:agent:${sessionId}`, + recordedAt: "2026-08-05T00:00:00Z", + task: { description: "Write the file", sourceMessageId: null }, + execution: { sessionId, turnId: "turn-1", replayOf: null }, + failureFingerprint: "unclassified-live-candidate/v1", + context: {}, + hypothesis: { cause: "unclassified", evidenceIds: [] }, + intervention: { + remedyId: "unclassified", + changedVariable: "unclassified", + }, + result: { outcome: "untested", evidenceIds: ["receipt-1"] }, + coverage: { + acp_observer: "observed", + acp_permission_gate: "missing", + host_workspace: "missing", + os_sandbox: "missing", + }, + relations: { supports: [], contradicts: [], invalidates: [] }, + ...overrides, + }, + }; +} + +test("keeps captured facts, claims, and missing coverage separate", () => { + const inspection = inspectCausalCandidate([entry("session-1")], "session-1"); + assert.equal(inspection?.status, "untested"); + assert.deepEqual(inspection?.inferredClaims, []); + assert.match( + inspection?.capturedFacts[1] ?? "", + /activity evidence was captured/, + ); + assert.deepEqual(inspection?.missingCoverage, [ + "Agent Client Protocol permission decisions", + "Host workspace effects", + "Operating-system sandbox effects", + ]); + assert.match(inspection?.nextGate ?? "", /independent evaluator/); +}); + +test("selects the latest ledger entry for the active session", () => { + const inspection = inspectCausalCandidate( + [ + entry("other"), + entry("session-1", { result: { outcome: "validated", evidenceIds: [] } }), + ], + "session-1", + ); + assert.equal(inspection?.status, "validated"); +}); + +test("does not show a candidate from another session", () => { + assert.equal(inspectCausalCandidate([entry("other")], "session-1"), null); +}); diff --git a/desktop/src/features/agents/ui/causalCandidateInspection.ts b/desktop/src/features/agents/ui/causalCandidateInspection.ts new file mode 100644 index 0000000000..4c68ddf717 --- /dev/null +++ b/desktop/src/features/agents/ui/causalCandidateInspection.ts @@ -0,0 +1,84 @@ +import type { + CausalExperiment, + EvidenceCoverage, + LedgerEntry, +} from "../lib/causalLedger"; + +export type CausalCandidateInspection = { + experimentId: string; + status: CausalExperiment["result"]["outcome"]; + task: string; + capturedFacts: string[]; + inferredClaims: string[]; + missingCoverage: string[]; + nextGate: string; +}; + +const COVERAGE_LABELS: Record = { + acp_observer: "Agent Client Protocol activity", + acp_permission_gate: "Agent Client Protocol permission decisions", + host_workspace: "Host workspace effects", + os_sandbox: "Operating-system sandbox effects", +}; + +function coverageLabel(key: string): string { + return COVERAGE_LABELS[key] ?? key.replaceAll("_", " "); +} + +function observedCoverage( + coverage: Record, +): string[] { + return Object.entries(coverage) + .filter(([, state]) => state === "observed") + .map(([key]) => coverageLabel(key)); +} + +function missingCoverage(coverage: Record): string[] { + return Object.entries(coverage) + .filter(([, state]) => state === "missing") + .map(([key]) => coverageLabel(key)); +} + +export function inspectCausalCandidate( + entries: readonly LedgerEntry[], + sessionId: string | null, +): CausalCandidateInspection | null { + if (!sessionId) return null; + const experiment = [...entries] + .reverse() + .find( + (entry) => entry.experiment.execution.sessionId === sessionId, + )?.experiment; + if (!experiment) return null; + + const capturedFacts = [ + `Session ${experiment.execution.sessionId} reached a terminal event.`, + ...observedCoverage(experiment.coverage).map( + (layer) => `${layer} evidence was captured.`, + ), + `${experiment.result.evidenceIds.length} evidence receipt${ + experiment.result.evidenceIds.length === 1 ? " was" : "s were" + } attached.`, + ]; + const inferredClaims = + experiment.hypothesis.cause === "unclassified" + ? [] + : [`Proposed cause: ${experiment.hypothesis.cause}`]; + const gaps = missingCoverage(experiment.coverage); + const nextGate = + experiment.result.outcome === "untested" + ? "Classify the failure, approve one controlled change, then require an independent evaluator before promotion." + : experiment.result.outcome === "inconclusive" + ? "Collect the missing evidence and rerun the same controlled comparison." + : "This result is evaluated; inspect its cited evidence before reusing the remedy."; + + return { + experimentId: experiment.experimentId, + status: experiment.result.outcome, + task: experiment.task.description, + capturedFacts, + inferredClaims, + missingCoverage: gaps, + nextGate, + }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 891e55299d..e7f7d28b3e 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -62,6 +62,7 @@ import { useLoadOlderOnScroll } from "@/features/messages/ui/useLoadOlderOnScrol import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; import { useChannelsQuery } from "@/features/channels/hooks"; import { CausalSessionTimeline } from "@/features/agents/ui/CausalSessionTimeline"; +import { CausalCandidateCard } from "@/features/agents/ui/CausalCandidateCard"; type AgentSessionThreadPanelProps = { agent: ChannelAgentSessionAgent; @@ -129,6 +130,12 @@ export function AgentSessionThreadPanel({ () => mergeObserverEventWindows(scopedEvents, archivedChannelEvents), [scopedEvents, archivedChannelEvents], ); + const activeSessionId = React.useMemo( + () => + combinedHeaderEvents.findLast((event) => Boolean(event.sessionId)) + ?.sessionId ?? null, + [combinedHeaderEvents], + ); const latestActivityAt = React.useMemo( () => getLatestActivityTimestamp(combinedHeaderEvents), [combinedHeaderEvents], @@ -497,6 +504,7 @@ export function AgentSessionThreadPanel({ events={combinedHeaderEvents} findings={[]} /> + { + const entries = await invokeTauri("read_causal_ledger"); + return entries.map((entry) => JSON.parse(entry) as LedgerEntry); +} + export function createTauriCausalLedgerPersistence(): CausalLedgerPersistence { return { async loadJournal() { - const entries = await invokeTauri("read_causal_ledger"); - return entries.join("\n"); + const entries = await readCausalLedger(); + return entries.map((entry) => JSON.stringify(entry)).join("\n"); }, async appendEntry(entry: LedgerEntry) { await invokeTauri("append_causal_ledger_entry", { From 768aa89febef97abeb7e48158ff289d0559f5c54 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Wed, 5 Aug 2026 16:05:47 -0400 Subject: [PATCH 08/14] fix(desktop): verify causal ledger integrity Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src-tauri/src/archive/causal_ledger.rs | 164 ++++++++++++++++-- 1 file changed, 147 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/src/archive/causal_ledger.rs b/desktop/src-tauri/src/archive/causal_ledger.rs index 96fd7f8032..9202249bec 100644 --- a/desktop/src-tauri/src/archive/causal_ledger.rs +++ b/desktop/src-tauri/src/archive/causal_ledger.rs @@ -1,5 +1,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; use tauri::State; use crate::app_state::AppState; @@ -14,13 +16,79 @@ struct LedgerEntryEnvelope { sequence: u64, previous_hash: String, hash: String, - experiment: ExperimentIdentity, + experiment: Value, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ExperimentIdentity { - experiment_id: String, +fn experiment_id(experiment: &Value) -> Result<&str, String> { + experiment + .get("experimentId") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| "causal ledger experimentId is required".to_string()) +} + +fn canonical_json(value: &Value) -> Result { + match value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + serde_json::to_string(value) + .map_err(|error| format!("failed to canonicalize causal ledger value: {error}")) + } + Value::Array(values) => { + let values = values + .iter() + .map(canonical_json) + .collect::, _>>()?; + Ok(format!("[{}]", values.join(","))) + } + Value::Object(entries) => { + let mut entries = entries.iter().collect::>(); + entries.sort_unstable_by_key(|(key, _)| *key); + let entries = entries + .into_iter() + .map(|(key, value)| { + let key = serde_json::to_string(key).map_err(|error| { + format!("failed to canonicalize causal ledger key: {error}") + })?; + Ok(format!("{key}:{}", canonical_json(value)?)) + }) + .collect::, String>>()?; + Ok(format!("{{{}}}", entries.join(","))) + } + } +} + +fn entry_hash(entry: &LedgerEntryEnvelope) -> Result { + let hash_input = serde_json::json!({ + "sequence": entry.sequence, + "previousHash": entry.previous_hash, + "experiment": entry.experiment, + }); + Ok(hex::encode(Sha256::digest( + canonical_json(&hash_input)?.as_bytes(), + ))) +} + +fn validate_entry(entry: &LedgerEntryEnvelope) -> Result<(), String> { + experiment_id(&entry.experiment)?; + if entry.sequence == 0 + || entry.hash.len() != 64 + || entry.previous_hash.len() != 64 + || !entry.hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + || !entry + .previous_hash + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err("invalid causal ledger chain fields".to_string()); + } + let expected = entry_hash(entry)?; + if entry.hash != expected { + return Err(format!( + "causal ledger integrity failure at sequence {}", + entry.sequence + )); + } + Ok(()) } /// Return the current owner's immutable causal-ledger journal in chain order. @@ -40,8 +108,25 @@ fn read_entries(conn: &Connection, identity: &str) -> Result, String let rows = statement .query_map(params![identity], |row| row.get::<_, String>(0)) .map_err(|error| format!("failed to read causal ledger: {error}"))?; - rows.collect::, _>>() - .map_err(|error| format!("failed to decode causal ledger row: {error}")) + let entries = rows + .collect::, _>>() + .map_err(|error| format!("failed to decode causal ledger row: {error}"))?; + let mut previous_hash = GENESIS_HASH.to_string(); + for (index, entry_json) in entries.iter().enumerate() { + let entry: LedgerEntryEnvelope = serde_json::from_str(entry_json) + .map_err(|error| format!("invalid stored causal ledger entry: {error}"))?; + validate_entry(&entry)?; + let expected_sequence = + u64::try_from(index).map_err(|_| "causal ledger sequence overflow".to_string())? + 1; + if entry.sequence != expected_sequence || entry.previous_hash != previous_hash { + return Err(format!( + "causal ledger integrity failure at sequence {}", + entry.sequence + )); + } + previous_hash = entry.hash; + } + Ok(entries) } /// Transactionally append one owner-scoped hash-linked causal-ledger entry. @@ -53,9 +138,7 @@ pub async fn append_causal_ledger_entry( let identity = identity_pubkey(&state)?; let entry: LedgerEntryEnvelope = serde_json::from_str(&entry_json) .map_err(|error| format!("invalid causal ledger entry: {error}"))?; - if entry.sequence == 0 || entry.hash.len() != 64 || entry.previous_hash.len() != 64 { - return Err("invalid causal ledger chain fields".to_string()); - } + validate_entry(&entry)?; let recorded_at = now_secs(); run_archive_db_task(move |conn| append_entry(conn, &identity, &entry_json, entry, recorded_at)) .await @@ -68,6 +151,7 @@ fn append_entry( entry: LedgerEntryEnvelope, recorded_at: i64, ) -> Result<(), String> { + validate_entry(&entry)?; conn.execute_batch("BEGIN IMMEDIATE") .map_err(|error| format!("failed to begin causal ledger append: {error}"))?; let result = (|| { @@ -98,7 +182,7 @@ fn append_entry( params![ identity, entry.sequence, - entry.experiment.experiment_id, + experiment_id(&entry.experiment)?, entry.previous_hash, entry.hash, entry_json, @@ -125,16 +209,18 @@ mod tests { use crate::archive::store::open_archive_db; fn envelope(sequence: u64, previous_hash: &str) -> (String, LedgerEntryEnvelope) { - let hash = format!("{sequence:064x}"); let experiment_id = format!("experiment-{sequence}"); - let json = serde_json::json!({ + let mut value = serde_json::json!({ "sequence": sequence, "previousHash": previous_hash, - "hash": hash, + "hash": "", "experiment": { "experimentId": experiment_id } - }) - .to_string(); - let parsed = serde_json::from_str(&json).expect("test envelope should decode"); + }); + let mut parsed: LedgerEntryEnvelope = + serde_json::from_value(value.clone()).expect("test envelope should decode"); + parsed.hash = entry_hash(&parsed).expect("hash test entry"); + value["hash"] = Value::String(parsed.hash.clone()); + let json = value.to_string(); (json, parsed) } @@ -168,4 +254,48 @@ mod tests { assert!(append_entry(&conn, "owner", &json, entry, 0).is_err()); assert!(read_entries(&conn, "owner").expect("read").is_empty()); } + + #[test] + fn rejects_a_forged_hash_without_writing_it() { + let conn = Connection::open_in_memory().expect("open in-memory database"); + conn.execute_batch(crate::archive::store::SCHEMA) + .expect("initialize schema"); + let (json, mut entry) = envelope(1, GENESIS_HASH); + entry.hash = "f".repeat(64); + assert!(append_entry(&conn, "owner", &json, entry, 0).is_err()); + assert!(read_entries(&conn, "owner").expect("read").is_empty()); + } + + #[test] + fn rejects_a_tampered_stored_experiment_on_read() { + let conn = Connection::open_in_memory().expect("open in-memory database"); + conn.execute_batch(crate::archive::store::SCHEMA) + .expect("initialize schema"); + let (json, entry) = envelope(1, GENESIS_HASH); + append_entry(&conn, "owner", &json, entry, 0).expect("append entry"); + conn.execute( + "UPDATE causal_ledger_entries SET entry_json = replace(entry_json, 'experiment-1', 'experiment-x')", + [], + ) + .expect("tamper stored entry"); + assert!(read_entries(&conn, "owner").is_err()); + } + + #[test] + fn hash_matches_the_browser_canonical_json_contract() { + let entry = LedgerEntryEnvelope { + sequence: 1, + previous_hash: GENESIS_HASH.to_string(), + hash: String::new(), + experiment: serde_json::json!({ + "schema": "causal-experiment/v1", + "experimentId": "golden", + "nested": { "z": true, "a": [1, "two", null] } + }), + }; + assert_eq!( + entry_hash(&entry).expect("hash golden entry"), + "514a88ba21863a1ea56a88e91e08aa382d249f46ffaf6c1eb797c745160490d8" + ); + } } From 0a56b019bc2e0c4ccb34566dde426bf7926181c4 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Wed, 5 Aug 2026 16:24:44 -0400 Subject: [PATCH 09/14] feat(desktop): approve controlled causal replays Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src/features/agents/lib/causalLedger.ts | 7 +- .../agents/lib/causalReplayProposal.test.mjs | 79 +++++++++ .../agents/lib/causalReplayProposal.ts | 67 ++++++++ .../agents/ui/CausalCandidateCard.tsx | 150 +++++++++++++++++- desktop/src/shared/api/tauriCausalLedger.ts | 29 +++- 5 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/agents/lib/causalReplayProposal.test.mjs create mode 100644 desktop/src/features/agents/lib/causalReplayProposal.ts diff --git a/desktop/src/features/agents/lib/causalLedger.ts b/desktop/src/features/agents/lib/causalLedger.ts index f285fcd7a8..8be59033a3 100644 --- a/desktop/src/features/agents/lib/causalLedger.ts +++ b/desktop/src/features/agents/lib/causalLedger.ts @@ -23,7 +23,12 @@ export type CausalExperiment = { environmentVersion: string; }; hypothesis: { cause: string; evidenceIds: string[] }; - intervention: { remedyId: string; changedVariable: string }; + intervention: { + remedyId: string; + changedVariable: string; + successCriteria?: string; + approvedAt?: string; + }; result: { outcome: ExperimentOutcome; evidenceIds: string[] }; coverage: Record; relations: { diff --git a/desktop/src/features/agents/lib/causalReplayProposal.test.mjs b/desktop/src/features/agents/lib/causalReplayProposal.test.mjs new file mode 100644 index 0000000000..59b931dd8d --- /dev/null +++ b/desktop/src/features/agents/lib/causalReplayProposal.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildApprovedReplayProposal } from "./causalReplayProposal.ts"; + +const candidate = { + schema: "causal-experiment/v1", + experimentId: "live:agent:session-1", + recordedAt: "2026-08-05T00:00:00Z", + task: { description: "Write the file", sourceMessageId: "message-1" }, + execution: { sessionId: "session-1", turnId: "turn-1", replayOf: null }, + failureFingerprint: "unclassified-live-candidate/v1", + context: { + codeVersion: "code-1", + policyVersion: "policy-1", + modelVersion: "model-1", + toolVersion: "tool-1", + environmentVersion: "env-1", + }, + hypothesis: { cause: "unclassified", evidenceIds: [] }, + intervention: { remedyId: "unclassified", changedVariable: "unclassified" }, + result: { outcome: "untested", evidenceIds: ["receipt-1"] }, + coverage: { acp_observer: "observed", os_sandbox: "missing" }, + relations: { supports: [], contradicts: [], invalidates: [] }, +}; + +test("builds one owner-approved controlled replay without inventing a result", () => { + const proposal = buildApprovedReplayProposal( + candidate, + { + failureFingerprint: "host-write-bypass/v1", + cause: "The host tool bypassed the governed path", + changedVariable: "execution path: host tool → ACP workspace tool", + successCriteria: + "Permission is requested before the write and the task succeeds", + }, + { experimentId: "proposal-1", recordedAt: "2026-08-05T01:00:00Z" }, + ); + + assert.equal(proposal.execution.replayOf, candidate.experimentId); + assert.equal(proposal.intervention.approvedAt, "2026-08-05T01:00:00Z"); + assert.equal(proposal.result.outcome, "untested"); + assert.deepEqual(proposal.result.evidenceIds, []); + assert.deepEqual(proposal.hypothesis.evidenceIds, ["receipt-1"]); +}); + +test("requires every causal and evaluation field before approval", () => { + assert.throws( + () => + buildApprovedReplayProposal( + candidate, + { + failureFingerprint: "host-write-bypass/v1", + cause: "A cause", + changedVariable: "one variable", + successCriteria: " ", + }, + { experimentId: "proposal-1", recordedAt: "2026-08-05T01:00:00Z" }, + ), + /Success criteria is required/, + ); +}); + +test("refuses to replay an already evaluated candidate", () => { + assert.throws( + () => + buildApprovedReplayProposal( + { ...candidate, result: { outcome: "validated", evidenceIds: [] } }, + { + failureFingerprint: "failure/v1", + cause: "A cause", + changedVariable: "one variable", + successCriteria: "A measured result", + }, + { experimentId: "proposal-1", recordedAt: "2026-08-05T01:00:00Z" }, + ), + /Only an untested candidate/, + ); +}); diff --git a/desktop/src/features/agents/lib/causalReplayProposal.ts b/desktop/src/features/agents/lib/causalReplayProposal.ts new file mode 100644 index 0000000000..8b2537bb3d --- /dev/null +++ b/desktop/src/features/agents/lib/causalReplayProposal.ts @@ -0,0 +1,67 @@ +import { + CAUSAL_EXPERIMENT_SCHEMA, + type CausalExperiment, +} from "./causalLedger"; + +export type ReplayProposalInput = { + failureFingerprint: string; + cause: string; + changedVariable: string; + successCriteria: string; +}; + +function required(label: string, value: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + return normalized; +} + +export function buildApprovedReplayProposal( + candidate: CausalExperiment, + input: ReplayProposalInput, + identity: { experimentId: string; recordedAt: string }, +): CausalExperiment { + if (candidate.result.outcome !== "untested") { + throw new Error( + "Only an untested candidate can start a controlled replay.", + ); + } + const failureFingerprint = required( + "Failure fingerprint", + input.failureFingerprint, + ); + const cause = required("Cause hypothesis", input.cause); + const changedVariable = required("Changed variable", input.changedVariable); + const successCriteria = required("Success criteria", input.successCriteria); + + return { + schema: CAUSAL_EXPERIMENT_SCHEMA, + experimentId: identity.experimentId, + recordedAt: identity.recordedAt, + task: candidate.task, + execution: { + sessionId: `replay-proposal:${candidate.execution.sessionId}`, + turnId: "pending-owner-approved-replay", + replayOf: candidate.experimentId, + }, + failureFingerprint, + context: candidate.context, + hypothesis: { + cause, + evidenceIds: [...candidate.result.evidenceIds], + }, + intervention: { + remedyId: `remedy:${identity.experimentId}`, + changedVariable, + successCriteria, + approvedAt: identity.recordedAt, + }, + result: { outcome: "untested", evidenceIds: [] }, + coverage: candidate.coverage, + relations: { + supports: [], + contradicts: [], + invalidates: [], + }, + }; +} diff --git a/desktop/src/features/agents/ui/CausalCandidateCard.tsx b/desktop/src/features/agents/ui/CausalCandidateCard.tsx index 1989a8f70b..8e214eee49 100644 --- a/desktop/src/features/agents/ui/CausalCandidateCard.tsx +++ b/desktop/src/features/agents/ui/CausalCandidateCard.tsx @@ -1,9 +1,18 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Eye, GitBranch, ShieldCheck } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; -import { readCausalLedger } from "@/shared/api/tauriCausalLedger"; +import { + appendCausalExperiment, + readCausalLedger, +} from "@/shared/api/tauriCausalLedger"; import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; +import { buildApprovedReplayProposal } from "../lib/causalReplayProposal"; import { inspectCausalCandidate } from "./causalCandidateInspection"; export function CausalCandidateCard({ @@ -11,6 +20,7 @@ export function CausalCandidateCard({ }: { sessionId: string | null; }) { + const queryClient = useQueryClient(); const query = useQuery({ queryKey: ["causal-ledger", sessionId], queryFn: readCausalLedger, @@ -18,6 +28,44 @@ export function CausalCandidateCard({ refetchInterval: 5_000, }); const inspection = inspectCausalCandidate(query.data ?? [], sessionId); + const candidate = query.data + ?.map((entry) => entry.experiment) + .find((experiment) => experiment.experimentId === inspection?.experimentId); + const approvedProposal = query.data + ?.map((entry) => entry.experiment) + .find( + (experiment) => + experiment.execution.replayOf === inspection?.experimentId && + experiment.intervention.approvedAt, + ); + const [draft, setDraft] = React.useState({ + failureFingerprint: "", + cause: "", + changedVariable: "", + successCriteria: "", + }); + const approveMutation = useMutation({ + mutationFn: async () => { + if (!candidate) throw new Error("The candidate is no longer available."); + const recordedAt = new Date().toISOString(); + const proposal = buildApprovedReplayProposal(candidate, draft, { + experimentId: `proposal:${candidate.experimentId}:${crypto.randomUUID()}`, + recordedAt, + }); + return appendCausalExperiment(proposal); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["causal-ledger"] }); + toast.success("Controlled replay approved and sealed in the ledger."); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Could not approve the replay.", + ); + }, + }); if (!inspection) return null; return ( @@ -71,10 +119,108 @@ export function CausalCandidateCard({ + + {approvedProposal ? ( +
+
+

+ Owner-approved replay +

+ Awaiting evaluator +
+

+ Change only: {approvedProposal.intervention.changedVariable} +

+

+ Success: {approvedProposal.intervention.successCriteria} +

+

+ Approval does not prove the remedy. Buzz will keep this untested + until a linked replay and independent evidence produce a verdict. +

+
+ ) : candidate?.result.outcome === "untested" ? ( + + setDraft((current) => ({ ...current, [field]: value })) + } + onSubmit={() => approveMutation.mutate()} + /> + ) : null} ); } +function ReplayApprovalForm({ + draft, + isPending, + onChange, + onSubmit, +}: { + draft: { + failureFingerprint: string; + cause: string; + changedVariable: string; + successCriteria: string; + }; + isPending: boolean; + onChange: (field: keyof typeof draft, value: string) => void; + onSubmit: () => void; +}) { + return ( +
{ + event.preventDefault(); + onSubmit(); + }} + > +
+

+ Controlled replay proposal +

+

+ Name the cause, change exactly one variable, and define success before + approving anything. +

+
+ onChange("failureFingerprint", event.target.value)} + placeholder="Failure fingerprint, for example host-write-bypass/v1" + value={draft.failureFingerprint} + /> +