From db2820eefd866be9da9f700830f9e9052f5ec3ed Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 13 Sep 2026 22:19:18 -0400 Subject: [PATCH] feat(app): render the unified lineage ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Files Changed render slice for the session lineage ledger (amicode#1082): a capability-gated view model that projects the #1078 browser-safe ledger records into one canonical-resource row each — final state, origin mix, source-session provenance, receipt count, evidence state — with expandable chronological receipt + assessment history (immutable execution facts preserved), a dedicated Unknown Mutation Receipts uncertainty group, paged resource grouping, explicit full/partial/legacy capability labels, and status states distinguishable by icon + text (never color alone). Keyboard operation (Enter/Space toggle, Escape collapse + refocus) and the LineageLedgerPanel component wire the Work Column accessibility contract; both themes via design tokens. Render slice only: no transport/rollout (#1083) and no E2E matrix (#1084). Consumes only server-projected facts; host-only capability/path/evidence/redaction fields never enter browser state. --- .../pages/session/v2/lineage-ledger-panel.css | 125 +++++ .../session/v2/lineage-ledger-panel.test.tsx | 71 +++ .../pages/session/v2/lineage-ledger-panel.tsx | 193 ++++++++ .../session/v2/lineage-ledger-view.test.ts | 334 +++++++++++++ .../pages/session/v2/lineage-ledger-view.ts | 444 ++++++++++++++++++ 5 files changed, 1167 insertions(+) create mode 100644 packages/app/src/pages/session/v2/lineage-ledger-panel.css create mode 100644 packages/app/src/pages/session/v2/lineage-ledger-panel.test.tsx create mode 100644 packages/app/src/pages/session/v2/lineage-ledger-panel.tsx create mode 100644 packages/app/src/pages/session/v2/lineage-ledger-view.test.ts create mode 100644 packages/app/src/pages/session/v2/lineage-ledger-view.ts diff --git a/packages/app/src/pages/session/v2/lineage-ledger-panel.css b/packages/app/src/pages/session/v2/lineage-ledger-panel.css new file mode 100644 index 000000000..c887b1eee --- /dev/null +++ b/packages/app/src/pages/session/v2/lineage-ledger-panel.css @@ -0,0 +1,125 @@ +/* Unified lineage-ledger panel (amicode#1082). Geometry + color come only from + * design-system tokens, so both themes are equal citizens and the status color + * is redundant to the icon + text label (color is never the only signal). */ + +[data-component="lineage-ledger"] { + display: flex; + flex-direction: column; + gap: var(--space-2, 8px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"] { + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); + padding: var(--space-3, 12px); + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-lg, 12px); + background: var(--v2-background-bg-layer-01); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"][data-mode="legacy"], +[data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"][data-mode="partial"] { + border-color: var(--v2-border-border-warning); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-filter"] { + padding: var(--space-1, 4px) var(--space-2, 8px); + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-md, 8px); + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-base); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-filter"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 1px; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-resources"] { + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-resource-row"] { + display: flex; + align-items: center; + gap: var(--space-2, 8px); + width: 100%; + padding: var(--space-1, 4px) var(--space-2, 8px); + border-radius: var(--radius-md, 8px); + background: transparent; + text-align: left; + cursor: pointer; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-resource-row"]:hover { + background: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-resource-row"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -1px; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-path"] { + flex: 1 1 auto; + min-width: 0; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"] { + display: inline-flex; + align-items: center; + gap: var(--space-1, 4px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="success"] { + color: var(--v2-state-fg-success); +} +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="danger"] { + color: var(--v2-state-fg-danger); +} +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="warning"] { + color: var(--v2-state-fg-warning); +} +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="neutral"] { + color: var(--v2-text-text-muted); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-history"] { + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); + padding: var(--space-1, 4px) var(--space-2, 8px) var(--space-2, 8px) var(--space-5, 20px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-unknown"] { + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); + padding: var(--space-2, 8px); + border: 1px solid var(--v2-border-border-warning); + border-radius: var(--radius-lg, 12px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-unknown-item"] { + display: flex; + align-items: center; + gap: var(--space-2, 8px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-more"] { + align-self: flex-start; + padding: var(--space-1, 4px) var(--space-2, 8px); + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-md, 8px); + background: var(--v2-background-bg-layer-01); + color: var(--v2-text-text-base); + cursor: pointer; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-more"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 1px; +} diff --git a/packages/app/src/pages/session/v2/lineage-ledger-panel.test.tsx b/packages/app/src/pages/session/v2/lineage-ledger-panel.test.tsx new file mode 100644 index 000000000..1fb332f24 --- /dev/null +++ b/packages/app/src/pages/session/v2/lineage-ledger-panel.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +// The lineage-ledger panel is authored in Solid JSX; bun's test transpiler does +// not run Solid's dom-expressions transform, so (as elsewhere in this repo — +// see vscode-explorer-file-icon.test.tsx) the component's behavioral substance +// lives in pure, directly-tested functions (lineage-ledger-view.test.ts) and +// this test proves the component wires the accessibility contract those +// functions require. The Work Column design contract (amicode#1082 AC8) is: +// Tab reaches each filter/row/continuation/unknown item, Enter/Space toggles a +// resource, Escape returns focus to its row, and each status has an accessible +// name plus a non-color signal. +const source = readFileSync(resolve(__dirname, "lineage-ledger-panel.tsx"), "utf8") + +describe("LineageLedgerPanel — render + accessibility contract", () => { + test("renders from the projected view-model only (no tool/watcher inference — AC1)", () => { + expect(source).toContain("./lineage-ledger-view") + expect(source).toContain("props.view()") + expect(source).not.toMatch(/accumulate-diffs|toolDiffs|externalFileStatus|watcher/i) + }) + + test("surfaces the capability label as accessible status text (AC7)", () => { + expect(source).toContain('data-slot="lineage-ledger-capability"') + expect(source).toContain('role="status"') + expect(source).toContain("capability().label") + expect(source).toContain("capability().description") + }) + + test("each canonical resource is one focusable, toggleable row (AC2/AC8)", () => { + expect(source).toContain('data-slot="lineage-ledger-resource"') + expect(source).toContain("aria-expanded") + expect(source).toContain("aria-controls") + // a real + + +
+ + {(entry) => ( +
+
+ {`#${entry.sequence} ${entry.operation ?? "operation"} → ${entry.outcome ?? "unknown"}`} + + {` (${entry.resource})`} + +
+ + {(assessment) => ( +
+ {`rev ${assessment.revision}: ${assessment.netState ?? "unknown"} · evidence ${assessment.evidenceState ?? "unavailable"}`} +
+ )} +
+
+ )} +
+
+
+ + )} + + + + +
+
+ {"Unknown Mutation Receipts"} +
+ + {(item) => ( +
+ + {`#${item.sequence} ${item.operation ?? "operation"} → ${item.outcome ?? "opaque"}`} + + {item.origin} + +
+ )} +
+
+
+ + + + + + ) +} diff --git a/packages/app/src/pages/session/v2/lineage-ledger-view.test.ts b/packages/app/src/pages/session/v2/lineage-ledger-view.test.ts new file mode 100644 index 000000000..5b6278b66 --- /dev/null +++ b/packages/app/src/pages/session/v2/lineage-ledger-view.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, test } from "bun:test" +import { + applyLedgerKeyDown, + buildLedgerView, + describeLedgerStatus, + LEDGER_CAPABILITY_LABELS, + ledgerCapabilityLabel, + pageReceiptHistory, + type LedgerProjectionRecord, + type LedgerStatusKind, +} from "./lineage-ledger-view" + +// A projected ledger record mirrors the #1078 `browser`-boundary projection of one +// host receipt: operation context (origin/state + display-safe session/resource +// keys), the immutable receipt fact, an optional assessment revision, and derived +// counts. Host-only fields (ids/rootID/sessionID/canonical path/evidence/redaction) +// are absent by construction — the browser boundary denies them. +function record(input: Partial): LedgerProjectionRecord { + return input +} + +describe("buildLedgerView — no-fallback ownership (AC1)", () => { + test("derives a resource's origin only from projected operation data, never inferred", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + operation: { origin: "agent", session: "root", state: "committed" }, + receipt: { id: "r1", sequence: 1, resource: "src/a.ts", operation: "create", outcome: "succeeded", timeCreated: 10 }, + assessment: { receiptID: "r1", netState: "added", evidenceState: "available", confidence: "observed", revision: 1, timeCreated: 11 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + expect(view.resources[0].origins).toEqual(["agent"]) + // The row exposes no tool/watcher-owned attribution field at all. + expect(view.resources[0]).not.toHaveProperty("tool") + expect(view.resources[0]).not.toHaveProperty("watcher") + }) +}) + +describe("buildLedgerView — one canonical resource row (AC2)", () => { + test("aggregates final state, origin mix, source provenance, receipt count, evidence state", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + operation: { origin: "user", session: "root" }, + receipt: { id: "r1", sequence: 1, resource: "src/a.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + assessment: { receiptID: "r1", netState: "added", evidenceState: "available", revision: 1, timeCreated: 2 }, + }), + record({ + operation: { origin: "agent", session: "child" }, + receipt: { id: "r2", sequence: 2, resource: "src/a.ts", operation: "edit", outcome: "succeeded", timeCreated: 3 }, + assessment: { receiptID: "r2", netState: "modified", evidenceState: "available", revision: 1, timeCreated: 4 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + const row = view.resources[0] + expect(row.displayPath).toBe("src/a.ts") + expect(row.receiptCount).toBe(2) + expect(row.origins.sort()).toEqual(["agent", "user"]) + expect(row.sources.sort()).toEqual(["child", "root"]) + expect(row.status.kind).toBe("modified") // final assessment + expect(row.evidenceState).toBe("available") + }) +}) + +describe("buildLedgerView — chronological history preserves immutable facts (AC3)", () => { + test("expands receipts in sequence with assessment revisions interleaved after their receipt", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + operation: { origin: "agent" }, + receipt: { id: "r1", sequence: 1, resource: "a.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + assessment: { receiptID: "r1", netState: "added", evidenceState: "available", revision: 1, timeCreated: 2 }, + }), + // later reassessment of the same receipt — a new revision, not a rewrite + record({ + assessment: { receiptID: "r1", netState: "reverted", evidenceState: "available", revision: 2, timeCreated: 5 }, + }), + record({ + operation: { origin: "agent" }, + receipt: { id: "r2", sequence: 2, resource: "a.ts", operation: "edit", outcome: "succeeded", timeCreated: 3 }, + }), + ], + }) + const row = view.resources[0] + expect(row.history.map((h) => h.receiptID)).toEqual(["r1", "r2"]) + expect(row.history[0].assessments.map((a) => a.revision)).toEqual([1, 2]) + }) + + test("a duplicate receipt record never rewrites the first-seen immutable execution fact", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + receipt: { id: "r1", sequence: 1, resource: "a.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + }), + record({ + receipt: { id: "r1", sequence: 1, resource: "a.ts", operation: "create", outcome: "TAMPERED", timeCreated: 99 }, + }), + ], + }) + expect(view.resources[0].history[0].outcome).toBe("succeeded") + expect(view.resources[0].history[0].timeCreated).toBe(1) + }) + + test("a zero-net final state never erases the resource's historical receipt row", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + receipt: { id: "r1", sequence: 1, resource: "tmp.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + assessment: { receiptID: "r1", netState: "added", evidenceState: "available", revision: 1, timeCreated: 2 }, + }), + record({ + receipt: { id: "r2", sequence: 2, resource: "tmp.ts", operation: "delete", outcome: "succeeded", timeCreated: 3 }, + assessment: { receiptID: "r2", netState: "deleted", evidenceState: "available", revision: 1, timeCreated: 4 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + expect(view.resources[0].receiptCount).toBe(2) + expect(view.resources[0].status.kind).toBe("deleted") + }) +}) + +describe("buildLedgerView — rename chain is one identity with aliases (AC2/AC3)", () => { + test("groups receipts sharing a server resource key across differing display paths", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + resourceKey: "res-1", + receipt: { id: "r1", sequence: 1, resource: "old.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + }), + record({ + resourceKey: "res-1", + receipt: { id: "r2", sequence: 2, resource: "new.ts", operation: "move", outcome: "succeeded", timeCreated: 2 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + expect(view.resources[0].displayPath).toBe("new.ts") + expect(view.resources[0].aliases).toEqual(["old.ts"]) + }) +}) + +describe("buildLedgerView — unknown mutation receipts are a dedicated uncertainty group (AC4)", () => { + test("a receipt with no resource identity becomes an uncertainty item, never a fabricated resource row", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + operation: { origin: "agent" }, + receipt: { id: "u1", sequence: 1, operation: "shell", outcome: "opaque", timeCreated: 1 }, + }), + record({ + receipt: { id: "r1", sequence: 2, resource: "a.ts", operation: "edit", outcome: "succeeded", timeCreated: 2 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + expect(view.resources.some((r) => r.displayPath === "")).toBe(false) + expect(view.unknown).toHaveLength(1) + expect(view.unknown[0].receiptID).toBe("u1") + // No patch/resource is fabricated for an unknown item. + expect(view.unknown[0]).not.toHaveProperty("patch") + expect(view.unknown[0]).not.toHaveProperty("resource") + }) + + test("unknown items do not consume resource-page capacity", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + page: { size: 2 }, + records: [ + record({ receipt: { id: "u1", sequence: 1, operation: "shell", outcome: "opaque", timeCreated: 1 } }), + record({ receipt: { id: "u2", sequence: 2, operation: "mcp", outcome: "opaque", timeCreated: 2 } }), + record({ receipt: { id: "r1", sequence: 3, resource: "a.ts", operation: "edit", outcome: "succeeded", timeCreated: 3 } }), + record({ receipt: { id: "r2", sequence: 4, resource: "b.ts", operation: "edit", outcome: "succeeded", timeCreated: 4 } }), + ], + }) + expect(view.resources).toHaveLength(2) + expect(view.unknown).toHaveLength(2) + expect(view.page.total).toBe(2) // resource total, unknowns excluded + expect(view.page.nextCursor).toBeUndefined() + }) +}) + +describe("describeLedgerStatus — distinguishable states without color alone (AC5)", () => { + const cases: Array<{ input: Parameters[0]; kind: LedgerStatusKind }> = [ + { input: { netState: "added", outcome: "succeeded" }, kind: "added" }, + { input: { netState: "modified", outcome: "succeeded" }, kind: "modified" }, + { input: { netState: "deleted", outcome: "succeeded" }, kind: "deleted" }, + { input: { netState: "reverted", outcome: "succeeded" }, kind: "reverted" }, + { input: { netState: "conflicted", outcome: "succeeded" }, kind: "conflicted" }, + { input: { netState: "unavailable", outcome: "succeeded", evidenceState: "unavailable" }, kind: "unavailable" }, + { input: { outcome: "opaque", netState: "opaque" }, kind: "opaque" }, + { input: { outcome: "partial", netState: "added" }, kind: "partial" }, + ] + + test("every named state yields a unique label + icon pair", () => { + const descriptors = cases.map((c) => describeLedgerStatus(c.input)) + for (const [i, c] of cases.entries()) expect(descriptors[i].kind).toBe(c.kind) + expect(new Set(descriptors.map((d) => d.label)).size).toBe(cases.length) + expect(new Set(descriptors.map((d) => d.icon)).size).toBe(cases.length) + }) + + test("distinguishers are text + icon (not color): tone is a semantic keyword, never a raw color", () => { + for (const c of cases) { + const d = describeLedgerStatus(c.input) + expect(d.label.length).toBeGreaterThan(0) + expect(d.icon.length).toBeGreaterThan(0) + expect(["success", "danger", "warning", "neutral"]).toContain(d.tone) + expect(d.tone).not.toMatch(/#|rgb|hsl/i) + } + }) + + test("accessible name always contains execution, assessment, and evidence state", () => { + const d = describeLedgerStatus({ outcome: "succeeded", netState: "added", evidenceState: "available" }) + expect(d.accessibleName.toLowerCase()).toContain("succeeded") + expect(d.accessibleName.toLowerCase()).toContain("added") + expect(d.accessibleName.toLowerCase()).toContain("available") + }) + + test("an unavailable assessment never hides the immutable execution fact", () => { + // real net state present + evidence unavailable → keep the execution/net fact, note evidence + const d = describeLedgerStatus({ outcome: "succeeded", netState: "modified", evidenceState: "unavailable" }) + expect(d.kind).toBe("modified") + expect(d.accessibleName.toLowerCase()).toContain("succeeded") + expect(d.accessibleName.toLowerCase()).toContain("unavailable") + }) + + test("theme-independent: the descriptor carries no theme branch or inline color", () => { + const light = describeLedgerStatus({ netState: "deleted", outcome: "succeeded" }) + const dark = describeLedgerStatus({ netState: "deleted", outcome: "succeeded" }) + expect(light).toEqual(dark) + }) +}) + +describe("buildLedgerView — paging preserves grouping and origin context (AC6)", () => { + test("pages resources while keeping each row's grouping and origins intact", () => { + const records: LedgerProjectionRecord[] = [] + for (let i = 0; i < 5; i++) { + records.push( + record({ + resourceKey: `res-${i}`, + operation: { origin: "agent", session: "root" }, + receipt: { id: `r${i}`, sequence: i + 1, resource: `f${i}.ts`, operation: "create", outcome: "succeeded", timeCreated: i }, + assessment: { receiptID: `r${i}`, netState: "added", evidenceState: "available", revision: 1, timeCreated: i }, + }), + ) + } + const first = buildLedgerView({ capability: { mode: "full" }, page: { size: 2 }, records }) + expect(first.resources).toHaveLength(2) + expect(first.page.total).toBe(5) + expect(first.page.nextCursor).toBe(2) + expect(first.resources[0].origins).toEqual(["agent"]) + + const next = buildLedgerView({ capability: { mode: "full" }, page: { size: 2, cursor: first.page.nextCursor }, records }) + expect(next.resources).toHaveLength(2) + expect(next.page.nextCursor).toBe(4) + }) + + test("pageReceiptHistory independently pages an expanded resource's history", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: Array.from({ length: 4 }, (_, i) => + record({ + resourceKey: "res-1", + receipt: { id: `r${i}`, sequence: i + 1, resource: "a.ts", operation: "edit", outcome: "succeeded", timeCreated: i }, + }), + ), + }) + const paged = pageReceiptHistory(view.resources[0], { cursor: 0, limit: 2 }) + expect(paged.entries.map((e) => e.receiptID)).toEqual(["r0", "r1"]) + expect(paged.nextCursor).toBe(2) + }) +}) + +describe("ledgerCapabilityLabel — full/partial/legacy fixed text (AC7)", () => { + test("each mode has fixed explanatory text selected only by capability", () => { + expect(ledgerCapabilityLabel("full")).toEqual(LEDGER_CAPABILITY_LABELS.full) + expect(ledgerCapabilityLabel("partial")).toEqual(LEDGER_CAPABILITY_LABELS.partial) + expect(ledgerCapabilityLabel("legacy")).toEqual(LEDGER_CAPABILITY_LABELS.legacy) + }) + + test("legacy and partial never imply full historical provenance", () => { + expect(LEDGER_CAPABILITY_LABELS.legacy.description.toLowerCase()).toContain("not") + expect(LEDGER_CAPABILITY_LABELS.partial.description.toLowerCase()).toMatch(/before|not|upgrad/) + expect(LEDGER_CAPABILITY_LABELS.full.description.toLowerCase()).not.toContain("legacy") + }) + + test("the built view surfaces the capability label for accessible status text", () => { + const view = buildLedgerView({ capability: { mode: "legacy" }, records: [] }) + expect(view.capability).toEqual(LEDGER_CAPABILITY_LABELS.legacy) + }) +}) + +describe("applyLedgerKeyDown — keyboard operation (AC8)", () => { + function ev(key: string) { + let prevented = false + return { event: { key, preventDefault: () => (prevented = true) }, prevented: () => prevented } + } + + test("Enter and Space on a resource row toggle it and prevent default", () => { + for (const key of ["Enter", " "]) { + const e = ev(key) + const action = applyLedgerKeyDown(e.event, { focusKind: "row", id: "res-1", expanded: false }) + expect(action).toEqual({ type: "toggle", id: "res-1" }) + expect(e.prevented()).toBe(true) + } + }) + + test("Escape inside expanded content collapses and returns focus to its row", () => { + const e = ev("Escape") + const action = applyLedgerKeyDown(e.event, { focusKind: "content", id: "res-1", expanded: true }) + expect(action).toEqual({ type: "collapse", id: "res-1", refocus: "res-1" }) + expect(e.prevented()).toBe(true) + }) + + test("Escape on an expanded row collapses it", () => { + const action = applyLedgerKeyDown(ev("Escape").event, { focusKind: "row", id: "res-1", expanded: true }) + expect(action).toEqual({ type: "collapse", id: "res-1", refocus: "res-1" }) + }) + + test("unrelated keys and collapsed-row escape are no-ops", () => { + expect(applyLedgerKeyDown(ev("a").event, { focusKind: "row", id: "res-1", expanded: false })).toEqual({ type: "none" }) + expect(applyLedgerKeyDown(ev("Escape").event, { focusKind: "row", id: "res-1", expanded: false })).toEqual({ type: "none" }) + }) +}) diff --git a/packages/app/src/pages/session/v2/lineage-ledger-view.ts b/packages/app/src/pages/session/v2/lineage-ledger-view.ts new file mode 100644 index 000000000..3c73aa32f --- /dev/null +++ b/packages/app/src/pages/session/v2/lineage-ledger-view.ts @@ -0,0 +1,444 @@ +// The unified lineage-ledger view-model (amicode#1082). +// +// This module is the render slice's data core: it turns the server's +// display-safe lineage-ledger projection (the #1078 `browser`-boundary +// projection — see packages/opencode/src/session/receipt-privacy.ts) into the +// Files Changed render model. It consumes ONLY projected facts and assessments; +// it has no path to tool-metadata or filesystem-watcher inference (amicode#1082 +// AC1), and no host-only capability, path, evidence, or redaction field is part +// of its input type (those are denied at the browser boundary by construction). + +/** Capability discovery result — the sole selector between the three views. */ +export type LedgerCapabilityMode = "full" | "partial" | "legacy" + +/** One immutable receipt fact as projected to the browser. */ +export type LedgerProjectionReceipt = { + id?: string + sequence?: number + resource?: string + operation?: string + outcome?: string + timeCreated?: number +} + +/** One append-only assessment revision as projected to the browser. */ +export type LedgerProjectionAssessment = { + receiptID?: string + confidence?: string + netState?: string + evidenceState?: string + revision?: number + expiresAt?: number + timeCreated?: number +} + +/** + * One browser-projected ledger record: operation context + an immutable receipt + * fact and/or an assessment revision + derived counts. `resourceKey` and + * `operation.session` are display-safe grouping identities the server projects + * (a stable logical resource id and a source-session label) — NOT the host-only + * canonical path or raw session id, which stay denied at this boundary. When + * `resourceKey` is absent the display path groups the resource. + */ +export type LedgerProjectionRecord = { + resourceKey?: string + operation?: { origin?: string; session?: string; state?: string } + receipt?: LedgerProjectionReceipt + assessment?: LedgerProjectionAssessment + derived?: { additions?: number; deletions?: number } +} + +export type LedgerCapabilityLabel = { + mode: LedgerCapabilityMode + label: string + description: string +} + +// Fixed explanatory text, selected only by capability discovery (amicode#1082 +// AC7). Legacy and partial never imply full historical provenance. +export const LEDGER_CAPABILITY_LABELS: Record = { + full: { + mode: "full", + label: "Full provenance", + description: "Every session-visible change is recorded in the lineage ledger.", + }, + partial: { + mode: "partial", + label: "Partial provenance", + description: "This session upgraded mid-run; changes made before the upgrade are not in the ledger.", + }, + legacy: { + mode: "legacy", + label: "Legacy view", + description: "This session predates the lineage ledger, so full historical provenance is not available.", + }, +} + +export function ledgerCapabilityLabel(mode: LedgerCapabilityMode): LedgerCapabilityLabel { + return LEDGER_CAPABILITY_LABELS[mode] +} + +// --- Status semantics (amicode#1082 AC5) -------------------------------------- + +export type LedgerStatusKind = + | "added" + | "modified" + | "deleted" + | "reverted" + | "conflicted" + | "unavailable" + | "opaque" + | "partial" + | "unknown" + +/** A semantic tone keyword (never a raw color) — color is redundant to icon + text. */ +export type LedgerStatusTone = "success" | "danger" | "warning" | "neutral" + +/** + * The status icon glyphs, as a literal subset of the shared icon-name union. + * Kept UI-package-free so the view model stays testable without the UI deps; + * every literal here is a valid `@opencode-ai/ui/icon` name, so the component + * assigns it to `Icon` with no cast. + */ +export type LedgerStatusIcon = + | "plus" + | "edit-small-2" + | "trash" + | "arrow-undo-down" + | "warning" + | "circle-ban-sign" + | "glasses" + | "dash" + | "help" + +export type LedgerStatusInput = { + operation?: string + outcome?: string + netState?: string + evidenceState?: string +} + +export type LedgerStatusDescriptor = { + kind: LedgerStatusKind + label: string + icon: LedgerStatusIcon + tone: LedgerStatusTone + accessibleName: string +} + +// Each state pairs a distinct text label with a distinct icon glyph, so the +// status is legible in both themes without depending on color. +const STATUS_PRESENTATION: Record = { + added: { label: "Added", icon: "plus", tone: "success" }, + modified: { label: "Modified", icon: "edit-small-2", tone: "neutral" }, + deleted: { label: "Deleted", icon: "trash", tone: "danger" }, + reverted: { label: "Reverted", icon: "arrow-undo-down", tone: "neutral" }, + conflicted: { label: "Conflicted", icon: "warning", tone: "danger" }, + unavailable: { label: "Unavailable", icon: "circle-ban-sign", tone: "warning" }, + opaque: { label: "Opaque", icon: "glasses", tone: "warning" }, + partial: { label: "Partial", icon: "dash", tone: "warning" }, + unknown: { label: "Unknown", icon: "help", tone: "warning" }, +} + +export function describeLedgerStatus(input: LedgerStatusInput): LedgerStatusDescriptor { + const kind = statusKind(input) + const presentation = STATUS_PRESENTATION[kind] + const accessibleName = `${presentation.label}. Execution ${input.outcome ?? "unknown"}. Assessment ${ + input.netState ?? "none" + }. Evidence ${input.evidenceState ?? "unavailable"}.` + return { kind, label: presentation.label, icon: presentation.icon, tone: presentation.tone, accessibleName } +} + +// Precedence (amicode#1082 deliberation): the immutable execution outcome is +// always reflected; the latest assessment supplies net + evidence; an +// unavailable *assessment* never hides the execution fact (only netState +// "unavailable" selects that kind — an unavailable evidence bit does not). +function statusKind(input: LedgerStatusInput): LedgerStatusKind { + if (input.outcome === "partial") return "partial" + if (input.netState === "conflicted" || input.outcome === "conflicted") return "conflicted" + if (input.netState === "unavailable") return "unavailable" + if (input.netState === "opaque" || (!input.netState && input.outcome === "opaque")) return "opaque" + if (input.netState === "added") return "added" + if (input.netState === "modified") return "modified" + if (input.netState === "deleted") return "deleted" + if (input.netState === "reverted") return "reverted" + const operation = (input.operation ?? "").toLowerCase() + if (operation.includes("delete") || operation.includes("trash") || operation.includes("remove")) return "deleted" + if (operation.includes("create") || operation.includes("add")) return "added" + if (operation.includes("revert") || operation.includes("restore")) return "reverted" + return "modified" +} + +// --- The view model ----------------------------------------------------------- + +export type LedgerAssessmentEntry = { + revision: number + confidence?: string + netState?: string + evidenceState?: string + expiresAt?: number + timeCreated?: number +} + +export type LedgerHistoryEntry = { + receiptID: string + sequence: number + operation?: string + outcome?: string + origin?: string + resource?: string + timeCreated?: number + assessments: LedgerAssessmentEntry[] +} + +export type LedgerResourceRow = { + id: string + displayPath: string + aliases: string[] + status: LedgerStatusDescriptor + origins: string[] + sources: string[] + receiptCount: number + evidenceState?: string + history: LedgerHistoryEntry[] +} + +export type LedgerUnknownItem = { + receiptID: string + sequence: number + operation?: string + outcome?: string + origin?: string + timeCreated?: number +} + +export type LedgerView = { + capability: LedgerCapabilityLabel + resources: LedgerResourceRow[] + unknown: LedgerUnknownItem[] + page: { size: number; cursor: number; nextCursor?: number; total: number } +} + +export type LedgerViewInput = { + capability: { mode: LedgerCapabilityMode } + records: readonly LedgerProjectionRecord[] + page?: { size?: number; cursor?: number } +} + +const DEFAULT_PAGE_SIZE = 50 + +export function buildLedgerView(input: LedgerViewInput): LedgerView { + const facts = collectFacts(input.records) + const rows = buildRows(facts).sort(byLatestAssessmentThenId) + const unknown = facts.unknown.slice().sort((a, b) => a.sequence - b.sequence) + + const size = input.page?.size ?? DEFAULT_PAGE_SIZE + const cursor = input.page?.cursor ?? 0 + const total = rows.length + const nextCursor = cursor + size < total ? cursor + size : undefined + + return { + capability: ledgerCapabilityLabel(input.capability.mode), + resources: rows.slice(cursor, cursor + size), + unknown, + page: { size, cursor, nextCursor, total }, + } +} + +/** Independently pages an expanded resource's receipt history (deliberation). */ +export function pageReceiptHistory(row: LedgerResourceRow, input: { cursor?: number; limit: number }) { + const cursor = input.cursor ?? 0 + const entries = row.history.slice(cursor, cursor + input.limit) + const nextCursor = cursor + input.limit < row.history.length ? cursor + input.limit : undefined + return { entries, nextCursor } +} + +type CollectedFact = { + fact: LedgerProjectionReceipt + origin?: string + session?: string + resourceKey?: string +} + +// First-seen wins for a receipt id: an immutable execution fact is never +// rewritten by a later record carrying the same id (amicode#1082 AC3). +function collectFacts(records: readonly LedgerProjectionRecord[]) { + const receipts = new Map() + const order: string[] = [] + const assessments = new Map>() + + for (const rec of records) { + const id = rec.receipt?.id + if (id && !receipts.has(id)) { + receipts.set(id, { + fact: rec.receipt!, + origin: rec.operation?.origin, + session: rec.operation?.session, + resourceKey: rec.resourceKey, + }) + order.push(id) + } + const assessment = rec.assessment + if (assessment?.receiptID) { + const revisions = assessments.get(assessment.receiptID) ?? new Map() + const revision = assessment.revision ?? 1 + if (!revisions.has(revision)) + revisions.set(revision, { + revision, + confidence: assessment.confidence, + netState: assessment.netState, + evidenceState: assessment.evidenceState, + expiresAt: assessment.expiresAt, + timeCreated: assessment.timeCreated, + }) + assessments.set(assessment.receiptID, revisions) + } + } + + const known: string[] = [] + const unknown: LedgerUnknownItem[] = [] + for (const id of order) { + const entry = receipts.get(id)! + // A receipt with no resource identity is an Unknown Mutation Receipt — an + // uncertainty item, never a fabricated resource row (amicode#1082 AC4). + if (!entry.fact.resource) { + unknown.push({ + receiptID: id, + sequence: entry.fact.sequence ?? 0, + operation: entry.fact.operation, + outcome: entry.fact.outcome, + origin: entry.origin, + timeCreated: entry.fact.timeCreated, + }) + continue + } + known.push(id) + } + + return { receipts, known, unknown, assessments } +} + +function buildRows(facts: ReturnType): LedgerResourceRow[] { + const groups = new Map() + const groupOrder: string[] = [] + for (const id of facts.known) { + const entry = facts.receipts.get(id)! + const key = entry.resourceKey ?? entry.fact.resource! + if (!groups.has(key)) { + groups.set(key, []) + groupOrder.push(key) + } + groups.get(key)!.push(id) + } + + return groupOrder.map((key) => { + const ids = groups + .get(key)! + .slice() + .sort((a, b) => sequenceOf(facts, a) - sequenceOf(facts, b)) + const history: LedgerHistoryEntry[] = ids.map((id) => { + const entry = facts.receipts.get(id)! + const revisions = [...(facts.assessments.get(id)?.values() ?? [])].sort((a, b) => a.revision - b.revision) + return { + receiptID: id, + sequence: entry.fact.sequence ?? 0, + operation: entry.fact.operation, + outcome: entry.fact.outcome, + origin: entry.origin, + resource: entry.fact.resource, + timeCreated: entry.fact.timeCreated, + assessments: revisions, + } + }) + + const latest = history[history.length - 1] + const displayPath = latest.resource ?? "" + const finalAssessment = latestAssessment(history) + return { + id: key, + displayPath, + aliases: distinct(history.map((entry) => entry.resource).filter((path): path is string => !!path && path !== displayPath)), + status: describeLedgerStatus({ + operation: latest.operation, + outcome: latest.outcome, + netState: finalAssessment?.netState, + evidenceState: finalAssessment?.evidenceState, + }), + origins: distinct(history.map((entry) => entry.origin).filter((origin): origin is string => !!origin)), + sources: distinct(ids.map((id) => facts.receipts.get(id)!.session).filter((session): session is string => !!session)), + receiptCount: ids.length, + evidenceState: finalAssessment?.evidenceState, + history, + } + }) +} + +// The final assessment is the one on the highest-sequence receipt, at its +// highest revision — the current net + evidence state for the resource. +function latestAssessment(history: LedgerHistoryEntry[]): LedgerAssessmentEntry | undefined { + let winner: LedgerAssessmentEntry | undefined + let winnerSequence = -1 + let winnerRevision = -1 + for (const entry of history) + for (const assessment of entry.assessments) + if (entry.sequence > winnerSequence || (entry.sequence === winnerSequence && assessment.revision > winnerRevision)) { + winner = assessment + winnerSequence = entry.sequence + winnerRevision = assessment.revision + } + return winner +} + +// Sort resources by latest assessment revision (most-reassessed first), then by +// stable resource id (amicode#1082 deliberation). +function byLatestAssessmentThenId(a: LedgerResourceRow, b: LedgerResourceRow): number { + const revisionDelta = maxRevision(b) - maxRevision(a) + if (revisionDelta !== 0) return revisionDelta + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 +} + +function maxRevision(row: LedgerResourceRow): number { + let max = 0 + for (const entry of row.history) for (const assessment of entry.assessments) max = Math.max(max, assessment.revision) + return max +} + +function sequenceOf(facts: ReturnType, id: string): number { + return facts.receipts.get(id)!.fact.sequence ?? 0 +} + +function distinct(values: string[]): string[] { + return [...new Set(values)] +} + +// --- Keyboard operation (amicode#1082 AC8) ------------------------------------ + +export type LedgerKeyContext = { + focusKind: "row" | "content" + id: string + expanded: boolean +} + +export type LedgerKeyAction = + | { type: "toggle"; id: string } + | { type: "collapse"; id: string; refocus: string } + | { type: "none" } + +// Enter or Space on a resource row toggles it; Escape collapses an expanded +// resource and returns focus to its row. Tab order is native DOM order (the +// component renders filters, rows, continuations, and unknown items in reading +// order), so it needs no reducer here. +export function applyLedgerKeyDown( + event: { key: string; preventDefault: () => void }, + context: LedgerKeyContext, +): LedgerKeyAction { + if (context.focusKind === "row" && (event.key === "Enter" || event.key === " ")) { + event.preventDefault() + return { type: "toggle", id: context.id } + } + if (event.key === "Escape" && context.expanded) { + event.preventDefault() + return { type: "collapse", id: context.id, refocus: context.id } + } + return { type: "none" } +}