From f9cbff8f4427f534d096ce0225e30fd9d53aea71 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 15 Sep 2026 00:34:37 +0200 Subject: [PATCH] feat(web): show an investigation as one agent's run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The investigation page stops drawing lanes. The provenance canvas is issue → incident → investigation → verdict → actions, with a process-only ghost where the verdict will land while the pass runs; the verdict card's running, failed and inconclusive shapes describe one agent; the Hypotheses tab, the lens fan, the lens-lane ledger and the "lenses in flight" hub stats go. What the run considered and dropped is the report's own `ruledOut`, which the inconclusive card already renders. The detail page syncs one Electric shape now — the investigation row — rather than the row plus its lanes. The wire still carries empty `lens_runs`, a null `validator` and the `fanout` bookkeeping for rows written before the rework; the next change drops them. --- .../investigations/flow/flow-nodes.tsx | 166 +--------- .../flow/provenance-canvas.test.tsx | 20 +- .../investigations/flow/provenance-canvas.tsx | 10 +- .../flow/provenance-graph.test.ts | 212 ++----------- .../investigations/flow/provenance-graph.ts | 223 ++------------ .../flow/provenance-loading.tsx | 73 ++--- .../investigations/hypotheses-tab.tsx | 133 -------- .../investigations/investigation-table.tsx | 34 +-- .../investigations/investigation-tabs.tsx | 11 +- .../investigations/investigation-view.tsx | 3 - .../investigations/lens-catalogue.ts | 86 ------ .../investigations/lens-derive.test.ts | 200 ------------- .../components/investigations/lens-derive.ts | 185 ------------ .../investigations/verdict-card.tsx | 283 ++---------------- .../settings/ai-triage-settings-section.tsx | 2 +- apps/web/src/hooks/use-investigation.ts | 23 +- .../lab/node-catalogue/node-catalogue.test.ts | 36 +-- .../src/lab/node-catalogue/node-catalogue.ts | 126 +------- .../collections/investigation-collections.ts | 24 +- .../lib/collections/investigations.test.ts | 94 +----- .../web/src/lib/collections/investigations.ts | 122 +------- apps/web/src/routes/investigations/index.tsx | 24 +- 22 files changed, 131 insertions(+), 1959 deletions(-) delete mode 100644 apps/web/src/components/investigations/hypotheses-tab.tsx delete mode 100644 apps/web/src/components/investigations/lens-catalogue.ts delete mode 100644 apps/web/src/components/investigations/lens-derive.test.ts delete mode 100644 apps/web/src/components/investigations/lens-derive.ts diff --git a/apps/web/src/components/investigations/flow/flow-nodes.tsx b/apps/web/src/components/investigations/flow/flow-nodes.tsx index 4620986f3..8078d8c38 100644 --- a/apps/web/src/components/investigations/flow/flow-nodes.tsx +++ b/apps/web/src/components/investigations/flow/flow-nodes.tsx @@ -18,12 +18,8 @@ import { BoltIcon, ChartBarIcon, CircleCheckIcon, - CircleQuestionIcon, - CircleXmarkIcon, ClockIcon, CodeIcon, - DotsIcon, - EyeIcon, type IconComponent, MagnifierIcon, NetworkNodesIcon, @@ -31,42 +27,15 @@ import { RocketIcon, ServerIcon, SlidersIcon, - SpinnerIcon, SquareTerminalIcon, } from "@/components/icons" -import type { LensTone } from "../lens-derive" import type { ActionKind } from "./action-target" -import type { - ActionNodeData, - FlowGlyph, - LensNodeData, - LensOverflowNodeData, - PendingVerdictNodeData, - SpineNodeData, -} from "./provenance-graph" +import type { ActionNodeData, FlowGlyph, PendingVerdictNodeData, SpineNodeData } from "./provenance-graph" /* ------------------------------------------------------------------------------------------------- * Shared * -----------------------------------------------------------------------------------------------*/ -const TONE_TEXT: Record = { - muted: "text-muted-foreground", - primary: "text-primary", - success: "text-success", - info: "text-info", - warning: "text-warning", - destructive: "text-destructive", -} satisfies Record - -const TONE_BORDER: Record = { - muted: "border-border", - primary: "border-primary", - success: "border-success", - info: "border-info", - warning: "border-warning", - destructive: "border-destructive", -} satisfies Record - const GLYPH: Record = { issue: AlertWarningIcon, check: RadioCheckedIcon, @@ -75,30 +44,6 @@ const GLYPH: Record = { verdict: CircleCheckIcon, } satisfies Record -/** - * One glyph per lane state, and only the running one is a loader. - * - * A queued lane and a reported lane both used to draw a stopped spinner, which - * said "this component is broken" rather than "this lane is waiting". They now - * split by what the lane is actually waiting on: - * - * - `queued` — nothing has happened. Bare dots, deliberately outside the circle - * family below: this lane has not entered the decision at all, which is the - * same thing its dashed border is saying. - * - `reported` — the lane did its work and the answer is undecided. A question - * mark completes the circle vocabulary the settled states already speak in, - * sitting exactly between the tick and the cross. - */ -const LENS_GLYPH: Record = { - queued: DotsIcon, - running: SpinnerIcon, - reported: CircleQuestionIcon, - confirmed: EyeIcon, - ruledOut: CircleXmarkIcon, - deadline: ClockIcon, - failed: CircleXmarkIcon, -} satisfies Record - /** The eyebrow strip: an icon in its tile, then the kind in caps. */ const EYEBROW = "text-[10px] font-medium uppercase tracking-[0.12em]" /** Both handles are hidden — this graph is read-only, nothing connects to anything. */ @@ -288,98 +233,8 @@ export const FlowSpineNode = memo(function FlowSpineNode({ data }: NodeProps & { ) }) -/* ------------------------------------------------------------------------------------------------- - * Lens - * -----------------------------------------------------------------------------------------------*/ - -export const FlowLensNode = memo(function FlowLensNode({ data }: NodeProps & { data: LensNodeData }) { - const { state } = data - const Icon = LENS_GLYPH[state.icon] - const running = state.icon === "running" - /** - * The lane's own sentence, on the lane. - * - * Every card used to keep this row for a skeleton bar and put the sentence in a - * `title` tooltip — which meant a settled lane displayed a loading placeholder - * that would never resolve, and the fan read as a column of cards still - * fetching. A running lane says what it is doing, everyone else says what they - * found. The row is the same row either way. - */ - const line = running ? (data.progressNote ?? data.result) : data.result - return ( - <> - -
-

- {data.title} -

-

- - - {state.word} - - - {data.elapsed ?? ""} - -

- {line ? ( -

- {line} -

- ) : null} - {/* - * On the card's bottom edge, not in the stack above it. As an inset - * rounded pill sitting in the content flow it belonged to nothing — - * a floating capsule with dead space under it — where flush against - * the border, clipped into the card's own corner radius, it reads as - * part of the card's frame. The 11px spinner stops being readable at - * the 0.68 zoom a narrow window forces; this does not. - */} - {running ? : null} -
- - ) -}) - /** - * The step after the fan while the verdict is still being reached. + * The step after the investigation while the verdict is still being reached. * * Dashed and muted throughout — it is the one node on the canvas that describes * work rather than a result, and it must not be mistaken at a glance for the @@ -426,23 +281,8 @@ export const FlowPendingVerdictNode = memo(function FlowPendingVerdictNode({ ) }) -export const FlowLensOverflowNode = memo(function FlowLensOverflowNode({ - data, -}: NodeProps & { data: LensOverflowNodeData }) { - return ( - <> - -
- - +{data.hidden} more {data.hidden === 1 ? "lens" : "lenses"} - -
- - ) -}) - /** - * A column heading — `FANNED OUT · 4 LENSES`. A node rather than an overlay so it + * A column heading — `PROPOSES · 3 ACTIONS BY IMPACT`. A node rather than an overlay so it * pans and zooms with the column it names; a heading that stays put while its * column slides out from under it is worse than none. */ diff --git a/apps/web/src/components/investigations/flow/provenance-canvas.test.tsx b/apps/web/src/components/investigations/flow/provenance-canvas.test.tsx index b4093234b..ed804bd39 100644 --- a/apps/web/src/components/investigations/flow/provenance-canvas.test.tsx +++ b/apps/web/src/components/investigations/flow/provenance-canvas.test.tsx @@ -94,30 +94,14 @@ const renderCanvas = (onOpenAction = vi.fn(), subject: V2Investigation = investi return onOpenAction } -/** Mid-run: a fan in flight, no report, so both ghost columns are up. */ +/** Mid-run: no report yet, so both ghost columns are up. */ // SAFETY: This fixture starts from a decoded investigation and overrides fields with valid variants. const running = { ...investigation, status: "investigating", report: null, diagnosed_at: null, - lens_runs: [ - { - lensId: "deploy_correlation", - status: "checking", - verdict: "pending", - claim: null, - reason: null, - progressNote: null, - confidence: null, - toolCount: 1, - elapsedSeconds: 2.1, - name: null, - question: null, - priority: null, - deadlineHit: false, - }, - ], + lens_runs: [], fanout: { state: "running", size: 1 }, } as never as V2Investigation diff --git a/apps/web/src/components/investigations/flow/provenance-canvas.tsx b/apps/web/src/components/investigations/flow/provenance-canvas.tsx index 8f1a14e34..c898ef1a5 100644 --- a/apps/web/src/components/investigations/flow/provenance-canvas.tsx +++ b/apps/web/src/components/investigations/flow/provenance-canvas.tsx @@ -32,8 +32,6 @@ import { FlowActionGhostNode, FlowActionNode, FlowHeadingNode, - FlowLensNode, - FlowLensOverflowNode, FlowPendingVerdictNode, FlowSpineNode, } from "./flow-nodes" @@ -49,8 +47,6 @@ import { buildProvenanceGraph, type ProvenanceGraph } from "./provenance-graph" */ export const nodeTypes = { spine: FlowSpineNode, - lens: FlowLensNode, - lensOverflow: FlowLensOverflowNode, pendingVerdict: FlowPendingVerdictNode, action: FlowActionNode, actionGhost: FlowActionGhostNode, @@ -273,7 +269,7 @@ function Canvas({ return () => observer.disconnect() }, [place]) - // New graph (a lens reported, the verdict landed) — replace it at whatever it grew into. + // New graph (the verdict landed) — replace it at whatever it grew into. useEffect(() => { userMovedRef.current = false place() @@ -384,10 +380,6 @@ function GraphOutline({
  • {node.type === "spine" ? ( `${node.data.eyebrow}: ${node.data.title}${node.data.status ? ` — ${node.data.status}` : ""}${node.data.phase ? ` — ${node.data.phase}` : ""}` - ) : node.type === "lens" ? ( - `Lens ${node.data.title} — ${node.data.state.word}${node.data.state.icon === "running" && node.data.progressNote ? `, ${node.data.progressNote}` : ""}` - ) : node.type === "lensOverflow" ? ( - `${node.data.hidden} further lenses` ) : node.type === "pendingVerdict" ? ( `${node.data.word}${node.data.note ? `: ${node.data.note}` : ""}` ) : node.type === "actionGhost" ? ( diff --git a/apps/web/src/components/investigations/flow/provenance-graph.test.ts b/apps/web/src/components/investigations/flow/provenance-graph.test.ts index 0cc10f673..d77cb86eb 100644 --- a/apps/web/src/components/investigations/flow/provenance-graph.test.ts +++ b/apps/web/src/components/investigations/flow/provenance-graph.test.ts @@ -1,26 +1,7 @@ import type { V2Investigation } from "@maple/domain/http/v2" import { describe, expect, it } from "vitest" -import type { LensRun } from "../lens-derive" -import { buildProvenanceGraph, selectLenses } from "./provenance-graph" - -const lens = (overrides: Partial = {}): LensRun => - ({ - lensId: "deploy_correlation", - status: "reported", - verdict: "ruled_out", - claim: "a deploy landed before the onset", - reason: null, - progressNote: null, - confidence: "medium", - toolCount: 3, - elapsedSeconds: 9.4, - name: null, - question: null, - priority: null, - deadlineHit: false, - ...overrides, - }) as LensRun +import { buildProvenanceGraph } from "./provenance-graph" const report = (overrides: Partial> = {}) => ({ @@ -77,25 +58,13 @@ const kinds = (investigation: V2Investigation) => describe("buildProvenanceGraph", () => { it("lays the full chain out left to right", () => { - const graph = buildProvenanceGraph( - make({ lens_runs: [lens(), lens({ verdict: "promoted" })] } as never), - ) - expect(kinds(make({ lens_runs: [lens()] } as never))).toEqual([ - "ISSUE", - "INCIDENT", - "INVESTIGATION", - "lens", - "VERDICT", - "action", - "action", - ]) - // Column origins are 198 apart (146 spine + 52 gutter) up to the lens column, - // which is 180 wide rather than 146 — so every origin after it is offset by - // that extra 34, and the actions column lands 34 further right. x stays - // strictly increasing and the graph never overlaps, which is what this pins. + const graph = buildProvenanceGraph(make()) + expect(kinds(make())).toEqual(["ISSUE", "INCIDENT", "INVESTIGATION", "VERDICT", "action", "action"]) + // Column origins are 198 apart (146 spine + 52 gutter). x stays strictly + // increasing and the graph never overlaps, which is what this pins. const xs = [...new Set(graph.nodes.map((node) => node.position.x))].sort((a, b) => a - b) - expect(xs).toEqual([0, 198, 396, 594, 826, 1024]) - expect(graph.width).toBe(1304) + expect(xs).toEqual([0, 198, 396, 594, 792]) + expect(graph.width).toBe(1072) }) /** @@ -148,32 +117,19 @@ describe("buildProvenanceGraph", () => { /** Spec rule 04: while investigating there is no verdict node — absent, not empty. */ it("omits the verdict and the real actions while the pass is still running", () => { - const running = kinds( - make({ - status: "investigating", - report: null, - lens_runs: [lens({ status: "checking" })], - } as never), - ) + const running = kinds(make({ status: "investigating", report: null } as never)) expect(running).not.toContain("VERDICT") expect(running).not.toContain("action") }) /** * Same argument as the pending-verdict ghost, one column further right: the - * chain used to stop dead at the fan's merge, which reads as finished. Two + * chain used to stop dead at the investigation, which reads as finished. Two * wordless cards say the column is still coming without claiming what will be * in it — the heading carries no count for the same reason. */ it("stands wordless ghosts where the proposed actions will land", () => { - const graph = buildProvenanceGraph( - make({ - status: "investigating", - report: null, - lens_runs: [lens({ status: "checking" })], - fanout: { state: "running", size: 1 }, - } as never), - ) + const graph = buildProvenanceGraph(make({ status: "investigating", report: null } as never)) const ghosts = graph.nodes.filter((node) => node.type === "actionGhost") expect(ghosts.map((node) => node.id)).toEqual(["action-ghost-0", "action-ghost-1"]) // No text on the wire at all — the only field is a layout slot. @@ -185,58 +141,32 @@ describe("buildProvenanceGraph", () => { expect(roadmap.every((edge) => edge.live)).toBe(true) }) - it("raises no action ghosts once the report has landed, or where there was no fan", () => { + it("raises no action ghosts once the run has ended", () => { const ghosts = (investigation: V2Investigation) => buildProvenanceGraph(investigation).nodes.filter((node) => node.type === "actionGhost") // Diagnosed: the real action nodes are there instead. expect(ghosts(make())).toHaveLength(0) expect(ghosts(make({ status: "failed", report: null } as never))).toHaveLength(0) - // No fan means no pending verdict to hang them off. - expect(ghosts(make({ status: "investigating", report: null } as never))).toHaveLength(0) }) /** * What stands where the verdict will go. It states the process and never a * finding, which is what keeps it from being the placeholder verdict the rule - * above forbids — and without it the chain stops mid-air at the fan. + * above forbids — and without it the chain stops mid-air at the investigation. */ it("stands a process-only ghost where the verdict will land", () => { - const graph = buildProvenanceGraph( - make({ - status: "investigating", - report: null, - lens_runs: [lens({ status: "reported", verdict: "pending" }), lens({ status: "checking" })], - fanout: { state: "running", size: 2 }, - } as never), - ) + const graph = buildProvenanceGraph(make({ status: "investigating", report: null } as never)) const ghost = graph.nodes.find((node) => node.type === "pendingVerdict") - expect(ghost).toMatchObject({ data: { word: "AWAITING VERDICT", note: "1 of 2 lenses reported" } }) + expect(ghost).toMatchObject({ data: { word: "AWAITING VERDICT", note: null } }) // No claim anywhere on it — no cause, no confidence, no instant. expect(Object.keys(ghost?.data ?? {})).toEqual(["word", "note"]) }) - it("says VALIDATING once every lane has settled", () => { - const graph = buildProvenanceGraph( - make({ - status: "investigating", - report: null, - lens_runs: [lens({ status: "reported", verdict: "pending" }), lens({ status: "no_finding" })], - fanout: { state: "validating", size: 2 }, - } as never), - ) - expect(graph.nodes.find((node) => node.type === "pendingVerdict")).toMatchObject({ - data: { word: "VALIDATING" }, - }) - }) - - it("raises no ghost once the run has ended, or where there was no fan", () => { + it("raises no ghost once the run has ended", () => { const ghosts = (investigation: V2Investigation) => buildProvenanceGraph(investigation).nodes.filter((node) => node.type === "pendingVerdict") expect(ghosts(make())).toHaveLength(0) expect(ghosts(make({ status: "failed", report: null } as never))).toHaveLength(0) - // A single-pass run dispatched no lenses; a ghost merging a fan of none is a - // column about work that was never split up. - expect(ghosts(make({ status: "investigating", report: null } as never))).toHaveLength(0) }) /** @@ -245,29 +175,12 @@ describe("buildProvenanceGraph", () => { * is spent on a diagnosis from last week. */ it("marks live only the strands feeding work that is still open", () => { - expect(buildProvenanceGraph(make({ lens_runs: [lens()] } as never)).edges.some((e) => e.live)).toBe( - false, - ) + expect(buildProvenanceGraph(make()).edges.some((e) => e.live)).toBe(false) - const graph = buildProvenanceGraph( - make({ - status: "investigating", - report: null, - lens_runs: [ - lens({ lensId: "settled", status: "reported", verdict: "pending" }), - lens({ lensId: "busy", status: "checking" }), - lens({ lensId: "waiting", status: "queued" }), - ], - fanout: { state: "running", size: 3 }, - } as never), - ) + const graph = buildProvenanceGraph(make({ status: "investigating", report: null } as never)) const live = graph.edges.filter((edge) => edge.live).map((edge) => edge.target) expect(live).toContain("investigation") expect(live).toContain("pending-verdict") - expect(live.some((id) => id.startsWith("lens-busy"))).toBe(true) - // Queued is live too — nothing has happened on that lane yet. - expect(live.some((id) => id.startsWith("lens-waiting"))).toBe(true) - expect(live.some((id) => id.startsWith("lens-settled"))).toBe(false) }) it("names the stage a running pass is in", () => { @@ -276,24 +189,7 @@ describe("buildProvenanceGraph", () => { const node = nodes.find((n) => n.id === "investigation") return node?.type === "spine" ? node.data.phase : undefined } - expect(phase({ fanout: { state: "queued", size: 3 } } as never)).toBe("QUEUEING") - expect( - phase({ - fanout: { state: "running", size: 2 }, - lens_runs: [lens({ status: "reported" }), lens({ status: "checking" })], - } as never), - ).toBe("FANNING OUT · 1/2") - expect(phase({ fanout: { state: "validating", size: 2 } } as never)).toBe("VALIDATING") - // The single-pass path, and the one that must not be labelled VALIDATING: - // `blocked` is set for the whole time the lanes are still reporting. - expect(phase({} as never)).toBe("RUNNING") - expect( - phase({ - fanout: { state: "running", size: 1 }, - lens_runs: [lens({ status: "checking" })], - validator: { status: "blocked", note: "waiting", elapsedSeconds: null }, - } as never), - ).toBe("FANNING OUT · 0/1") + expect(phase({})).toBe("GATHERING EVIDENCE") // A finished run states its outcome, not a stage. const done = chain(make()).find((node) => node.id === "investigation") expect(done?.type === "spine" ? done.data.phase : "set").toBeUndefined() @@ -316,7 +212,7 @@ describe("buildProvenanceGraph", () => { /** Spec rule 01: exactly one amber node — a second means "you are here" has stopped meaning anything. */ it("marks exactly one node as current", () => { - const graph = buildProvenanceGraph(make({ lens_runs: [lens(), lens()] } as never)) + const graph = buildProvenanceGraph(make()) const current = graph.nodes.filter((node) => node.type === "spine" && node.data.current) expect(current).toHaveLength(1) }) @@ -333,7 +229,7 @@ describe("buildProvenanceGraph", () => { */ it("never prints a raw identifier on a node", () => { const uuidish = /[0-9a-f]{8}-[0-9a-f]{4}/i - for (const node of chain(make({ lens_runs: [lens()] } as never))) { + for (const node of chain(make())) { if (node.type !== "spine") continue expect(node.data.title).not.toMatch(uuidish) } @@ -390,41 +286,6 @@ describe("buildProvenanceGraph", () => { }) }) - /** - * The held count was the deleted checks rail's header — the fastest read of - * whether the verdict deserves trust — so it moved onto the column heading - * rather than being lost with the rail. - */ - it("counts the lenses that held once the validator has ranked", () => { - const ranked = make({ - lens_runs: [lens({ verdict: "promoted" }), lens({ verdict: "ruled_out" })], - validator: { status: "ranked", note: "1 promoted", elapsedSeconds: 8.2 }, - } as never) - expect(buildProvenanceGraph(ranked).lensHeading).toBe("FANNED OUT · 2 LENSES · 1 HELD") - }) - - it("withholds the held count while the lenses are still running", () => { - const running = make({ - status: "investigating", - report: null, - lens_runs: [lens({ status: "checking" }), lens({ status: "queued" })], - validator: { status: "blocked", note: "waiting", elapsedSeconds: null }, - } as never) - // "0 held" over two lanes nobody has ranked states a result the run never reached. - expect(buildProvenanceGraph(running).lensHeading).toBe("FANNED OUT · 2 LENSES") - }) - - it("carries the validator's own sentence on the lens it belongs to", () => { - const graph = buildProvenanceGraph( - make({ - lens_runs: [lens({ reason: "callee percentiles stayed flat across the window" })], - } as never), - ) - expect(graph.nodes.find((node) => node.type === "lens")).toMatchObject({ - data: { result: "callee percentiles stayed flat across the window" }, - }) - }) - it("titles the investigation node with how the run went", () => { expect(chain(make()).find((node) => node.id === "investigation")).toMatchObject({ data: { title: "Diagnosed" }, @@ -440,36 +301,3 @@ describe("buildProvenanceGraph", () => { expect(buildProvenanceGraph(make()).caption).toMatch(/→.*· 38s$/) }) }) - -describe("selectLenses", () => { - /** Spec rule 02: above five, the fan draws four strands plus a "+N more" — never N. */ - it("collapses above five lenses to the top four by priority", () => { - const runs = [1, 2, 3, 4, 5, 6, 7].map((priority) => - lens({ lensId: `lens_${priority}`, priority: 8 - priority }), - ) - const { visible, hidden } = selectLenses(runs) - expect(hidden).toBe(3) - // Ranked by priority, then restored to dispatch order so the fan reads - // top-to-bottom as it actually ran. - expect(visible.map((run) => run.lensId)).toEqual(["lens_4", "lens_5", "lens_6", "lens_7"]) - }) - - it("shows all five rather than four and a +1", () => { - const runs = [1, 2, 3, 4, 5].map((n) => lens({ lensId: `lens_${n}` })) - expect(selectLenses(runs)).toMatchObject({ hidden: 0 }) - expect(selectLenses(runs).visible).toHaveLength(5) - }) - - /** Lanes written before the planner carry no priority — they sort last but keep their order. */ - it("sinks unprioritised lanes without reshuffling them", () => { - const runs = [ - lens({ lensId: "a" }), - lens({ lensId: "b", priority: 1 }), - lens({ lensId: "c" }), - lens({ lensId: "d", priority: 2 }), - lens({ lensId: "e" }), - lens({ lensId: "f" }), - ] - expect(selectLenses(runs).visible.map((run) => run.lensId)).toEqual(["a", "b", "c", "d"]) - }) -}) diff --git a/apps/web/src/components/investigations/flow/provenance-graph.ts b/apps/web/src/components/investigations/flow/provenance-graph.ts index 9bcb80ced..dcfe1af0f 100644 --- a/apps/web/src/components/investigations/flow/provenance-graph.ts +++ b/apps/web/src/components/investigations/flow/provenance-graph.ts @@ -8,22 +8,13 @@ * * Deliberately free of React and of `@xyflow/react` runtime imports: the layout * is arithmetic over a fixed column grid, and keeping it that way is what lets - * the shape rules (which node kinds appear, how the lens column collapses) be - * unit-tested without mounting a canvas. + * the shape rules (which node kinds appear) be unit-tested without mounting a + * canvas. */ import type { V2Investigation } from "@maple/domain/http/v2" import { formatNumber } from "@maple/ui/lib/format" import { toEpochMs } from "@maple/ui/lib/time-format" -import { lensCopy } from "../lens-catalogue" -import { - type LensRun, - type LensNodeState, - checksHeld, - lensChecks, - lensNodeState, - lensTally, -} from "../lens-derive" import { splitDuration } from "../investigation-display" import { classifyAction, @@ -36,23 +27,11 @@ import { * Geometry * * Taken from the Paper frame rather than eyeballed: spine nodes are 146 wide with - * a 52px gutter (146 + 52 = 198 between column origins), lens nodes 146×52, the - * actions column 280. The right edge lands at 990 + 280 = 1270, the width of the + * a 52px gutter (146 + 52 = 198 between column origins), the actions column 280. The right edge lands at 990 + 280 = 1270, the width of the * design's graph frame. * -----------------------------------------------------------------------------------------------*/ export const SPINE_WIDTH = 146 -/** - * Wider than the design's 146, and wider than the spine beside it. - * - * At 146 the text column is 126px, which is seventeen 12px mono characters — - * shorter than almost every lane name the planner writes, so the entire fan - * rendered as a column of identical `Deploy correlati…`. A card whose title is - * mostly ellipsis reads as broken layout no matter how well the rest of it is - * drawn. 180 buys twenty-two characters, which clears the catalogue names and - * most planner-written ones. - */ -export const LENS_WIDTH = 180 export const ACTION_WIDTH = 280 const GUTTER = 52 @@ -87,18 +66,13 @@ export const SPINE_HEIGHT_LIVE = 168 * summing one height per node, and a fan whose rows changed height every poll * would walk up and down the canvas as lanes settled. */ -export const LENS_HEIGHT = 64 -const LENS_GAP = 8 +const COLUMN_GAP = 8 export const ACTION_HEIGHT = 76 const ACTION_GAP = 8 export const HEADING_HEIGHT = 12 /** Column headings ride 20px above their column's top edge. */ const HEADING_OFFSET = 20 -/** Above five lenses the column collapses to the top four plus a "+N more" node. */ -const LENS_VISIBLE_MAX = 4 -const LENS_COLLAPSE_ABOVE = 5 - /* ------------------------------------------------------------------------------------------------- * Node model * -----------------------------------------------------------------------------------------------*/ @@ -135,33 +109,12 @@ export interface SpineNodeData { readonly lifted?: boolean /** This step is happening right now — the node rings and its strands march. */ readonly live?: boolean - /** What the live step is doing, e.g. `FANNING OUT · 2/4 REPORTED`. Only set while `live`. */ + /** What the live step is doing, e.g. `GATHERING EVIDENCE`. Only set while `live`. */ readonly phase?: string } -export interface LensNodeData { - readonly title: string - readonly question: string - /** The validator's own sentence for this lane — hover text, not a printed row. */ - readonly result: string - readonly state: LensNodeState - readonly elapsed: string | null - /** - * What the lane is doing right now, printed while it runs. - * - * It was already on the wire and already derived — `lensChecks` reads it for - * the rail — but on this canvas it only ever reached a `title` tooltip, which - * is the one place a reader watching a run in progress will not look. - */ - readonly progressNote: string | null -} - -export interface LensOverflowNodeData { - readonly hidden: number -} - /** - * The step after the fan, while there is no verdict to show. + * The step after the investigation, while there is no verdict to show. * * It states the *process* and never a result: no suspected cause, no confidence, * no timestamp. A placeholder verdict would be a claim the run has not made; a @@ -206,15 +159,6 @@ export interface ActionNodeData { export type ProvenanceNode = | { id: string; type: "spine"; position: XY; width: number; height: number; data: SpineNodeData } - | { id: string; type: "lens"; position: XY; width: number; height: number; data: LensNodeData } - | { - id: string - type: "lensOverflow" - position: XY - width: number - height: number - data: LensOverflowNodeData - } | { id: string type: "pendingVerdict" @@ -246,7 +190,7 @@ export interface XY { readonly y: number } -export type EdgeKind = "causal" | "fan" | "roadmap" +export type EdgeKind = "causal" | "roadmap" export interface ProvenanceEdge { readonly id: string @@ -272,8 +216,6 @@ export interface ProvenanceGraph { readonly edges: ReadonlyArray readonly width: number readonly height: number - /** Column heading above the lens fan, e.g. `FANNED OUT · 4 LENSES`. Null when there was no fan-out. */ - readonly lensHeading: string | null /** Column heading above the actions column. Null when the report proposed nothing. */ readonly actionHeading: string | null /** `14:02 → 14:03 · 38s`, or as much of it as the timestamps carry. */ @@ -315,8 +257,8 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance const pushColumn = (nodes: Array, width: number, kind: EdgeKind) => { if (nodes.length === 0) return - // A fan into many nodes, or a merge out of many, carries its label on the - // column heading instead — the same word repeated on four strands is noise. + // A column of many nodes carries its label on the column heading instead — + // the same word repeated on three strands is noise. const label = upstream.length === 1 && nodes.length === 1 ? nextLabel : undefined for (const source of upstream) { for (const node of nodes) { @@ -431,50 +373,6 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance ) if (running) liveIds.add("investigation") - /* --- the lens fan -------------------------------------------------------- */ - - const { visible, hidden } = selectLenses(investigation.lens_runs) - // `lensChecks` returns one entry per lane in the order given, so this is the - // validator's own sentence for each visible lane. It used to be the checks - // rail's second line; with the rail gone it is the node's hover text, and it is - // the only place the *reason* a lane held or didn't survives on this tab. - const results = lensChecks(visible) - const lensNodes: Array = visible.map((run, index) => { - const id = `lens-${run.lensId}-${index}` - // A queued lane is live too: nothing has happened on it yet, which is - // precisely why its strand should not read as settled. - if (run.status === "checking" || run.status === "queued") liveIds.add(id) - return { - id, - type: "lens" as const, - position: { x: 0, y: 0 }, - width: LENS_WIDTH, - height: LENS_HEIGHT, - data: { - title: lensCopy(run).name, - question: run.question ?? "", - result: results[index]?.result ?? "", - state: lensNodeState(run), - elapsed: run.elapsedSeconds == null ? null : `${run.elapsedSeconds.toFixed(1)}s`, - progressNote: run.progressNote, - }, - } - }) - if (hidden > 0) { - lensNodes.push({ - id: "lens-overflow", - type: "lensOverflow", - position: { x: 0, y: 0 }, - width: LENS_WIDTH, - height: LENS_HEIGHT, - data: { hidden }, - }) - } - if (lensNodes.length > 0) { - nextLabel = "FANNED OUT" - pushColumn(lensNodes, LENS_WIDTH, "fan") - } - /* --- verdict ------------------------------------------------------------- */ /* @@ -482,7 +380,7 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance * the investigation has not made yet. What stands in its place is a node about * the process rather than the finding — see `PendingVerdictNodeData`. */ - const awaitingVerdict = running && lensNodes.length > 0 + const awaitingVerdict = running if (awaitingVerdict) { pushColumn( [ @@ -492,11 +390,11 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance position: { x: 0, y: 0 }, width: SPINE_WIDTH, height: SPINE_HEIGHT_TALL, - data: pendingVerdict(investigation), + data: { word: "AWAITING VERDICT", note: null }, }, ], SPINE_WIDTH, - "fan", + "causal", ) liveIds.add("pending-verdict") } else if (!running && report) { @@ -538,7 +436,7 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance }, ], SPINE_WIDTH, - "fan", + "causal", ) } @@ -598,12 +496,11 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance return kind === "action" || kind === "actionGhost" } const columnHeight = (column: { nodes: Array }): number => { - const gap = isActionColumn(column) ? ACTION_GAP : LENS_GAP + const gap = isActionColumn(column) ? ACTION_GAP : COLUMN_GAP return column.nodes.reduce((total, node, index) => total + node.height + (index ? gap : 0), 0) } const height = Math.max(0, ...columns.map(columnHeight)) - const lensHeading = lensColumnHeading(investigation) const actionHeading = proposing ? `PROPOSES · ${actions.length} ${actions.length === 1 ? "ACTION" : "ACTIONS"} BY IMPACT` : // No count while the ghosts stand there — a count is a claim about a report @@ -615,18 +512,17 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance const headings: Array = [] let x = 0 for (const column of columns) { - const gap = isActionColumn(column) ? ACTION_GAP : LENS_GAP + const gap = isActionColumn(column) ? ACTION_GAP : COLUMN_GAP const top = (height - columnHeight(column)) / 2 let y = top for (const node of column.nodes) { ;(node as { position: XY }).position = { x, y } y += node.height + gap } - // The two multi-node columns carry their count as a heading; the spine - // columns say what they are on the node itself. + // The actions column carries its count as a heading; the spine columns say + // what they are on the node itself. const kind = column.nodes[0]?.type - const text = - kind === "action" || kind === "actionGhost" ? actionHeading : kind === "lens" ? lensHeading : null + const text = kind === "action" || kind === "actionGhost" ? actionHeading : null if (text) { headings.push({ id: `heading-${kind}`, @@ -647,7 +543,6 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance edges: edges.map((edge) => (liveIds.has(edge.target) ? { ...edge, live: true } : edge)), width: Math.max(0, x - GUTTER), height, - lensHeading, actionHeading, caption: caption(investigation), runningSince: running && Number.isFinite(openedMs) ? openedMs : null, @@ -658,32 +553,6 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance * Derivations * -----------------------------------------------------------------------------------------------*/ -/** - * The top four by planner priority, with the rest folded into one node. - * - * Priority ascends (1 is highest), and lanes written before the planner carry - * none — those sort last but keep their dispatch order, so the column is never - * reshuffled arbitrarily. Below the collapse threshold every lane is shown: four - * strands and a "+1 more" reads worse than five strands. - */ -export function selectLenses(runs: ReadonlyArray): { - visible: ReadonlyArray - hidden: number -} { - if (runs.length <= LENS_COLLAPSE_ABOVE) return { visible: runs, hidden: 0 } - const ranked = runs - .map((run, index) => ({ run, index })) - .sort((a, b) => { - const left = a.run.priority ?? Number.POSITIVE_INFINITY - const right = b.run.priority ?? Number.POSITIVE_INFINITY - return left - right || a.index - b.index - }) - .slice(0, LENS_VISIBLE_MAX) - // Back into dispatch order, so the fan reads top-to-bottom as it ran. - .sort((a, b) => a.index - b.index) - return { visible: ranked.map((entry) => entry.run), hidden: runs.length - ranked.length } -} - /** The name the origin node prints — the exception, not the opaque `iss_…`. */ const originTitle = (investigation: V2Investigation): string | null => { const { snapshot } = investigation @@ -692,23 +561,6 @@ const originTitle = (investigation: V2Investigation): string | null => { return text ? text : null } -/** - * `FANNED OUT · 4 LENSES · 1 HELD`. - * - * The held count was the checks rail's header, and it is the fastest read of - * whether the verdict deserves trust — so when the rail went it came here rather - * than being lost. It is withheld until the validator has actually ranked: "0 - * held" printed over four still-running lanes states a result nobody reached. - */ -const lensColumnHeading = (investigation: V2Investigation): string | null => { - const lenses = investigation.lens_runs - if (lenses.length === 0) return null - const base = `FANNED OUT · ${lenses.length} ${lenses.length === 1 ? "LENS" : "LENSES"}` - const status = investigation.validator?.status - if (status !== "ranked" && status !== "rejected_all") return base - return `${base} · ${checksHeld(lensChecks(lenses))} HELD` -} - /** * What the incident *was*, not what it is called. * @@ -728,43 +580,8 @@ const incidentTitle = (investigation: V2Investigation): string => { return subject.type === "incident" ? `${subject.incident_kind} incident` : "Incident" } -/** - * Which stage a running pass is in, for the amber node's second line. - * - * `running` is checked before the validator, because `blocked` means "the - * validator has nothing to rank yet" and is set for the whole time the lanes are - * still reporting — reading it first would label a fan-out mid-flight - * "VALIDATING", which is the one stage it demonstrably is not in. - */ -const livePhase = (investigation: V2Investigation): string => { - const state = investigation.fanout?.state - if (state === "queued") return "QUEUEING" - if (state === "running") { - const tally = lensTally(investigation.lens_runs) - // The bare ratio, not "1/3 REPORTED": the node is 146px and the longer form - // truncated to "FANNING OUT · 1/3…", losing the word it was spent on. What - // the ratio counts is stated by the fan it sits next to. - return tally.total > 0 ? `FANNING OUT · ${tally.settled}/${tally.total}` : "FANNING OUT" - } - if (state === "validating" || investigation.validator?.status === "blocked") return "VALIDATING" - // The single-pass path — a freeform question, or an org that opted out of planning. - return "RUNNING" -} - -/** - * The ghost step's two lines. Both describe work, never a finding: `settled` - * counts lanes that will not change again, which is the same number the fan is - * showing, so the two cannot disagree. - */ -const pendingVerdict = (investigation: V2Investigation): PendingVerdictNodeData => { - const tally = lensTally(investigation.lens_runs) - const validating = - investigation.fanout?.state === "validating" || (tally.total > 0 && tally.settled === tally.total) - return { - word: validating ? "VALIDATING" : "AWAITING VERDICT", - note: tally.total > 0 ? `${tally.settled} of ${tally.total} lenses reported` : null, - } -} +/** The amber node's second line while the pass runs: one agent, one stage. */ +const livePhase = (_investigation: V2Investigation): string => "GATHERING EVIDENCE" /** The run's outcome, which is the one thing the canvas doesn't say anywhere else. */ // `Record` with a `?? status` fallback downstream, so a missing diff --git a/apps/web/src/components/investigations/flow/provenance-loading.tsx b/apps/web/src/components/investigations/flow/provenance-loading.tsx index a072dff58..c21033473 100644 --- a/apps/web/src/components/investigations/flow/provenance-loading.tsx +++ b/apps/web/src/components/investigations/flow/provenance-loading.tsx @@ -2,9 +2,9 @@ * The provenance canvas before it has anything to draw. * * Rather than a grey block standing in for the page's lead widget, this previews - * the destination: the same left-to-right chain — spine, lens fan, verdict, - * proposed actions — as ghost cards that assemble on the same column grid the - * real graph uses, wired by the same square-cornered strands. + * the destination: the same left-to-right chain — spine, verdict, proposed + * actions — as ghost cards that assemble on the same column grid the real graph + * uses, wired by the same square-cornered strands. * * Inline SVG on the real graph's 1270-wide frame, for the reason * `service-map-loading.tsx` is: it scales to any pane with no measurement pass, @@ -17,7 +17,7 @@ */ import type { CSSProperties } from "react" -import { ACTION_WIDTH, LENS_WIDTH, SPINE_WIDTH } from "./provenance-graph" +import { ACTION_WIDTH, SPINE_WIDTH } from "./provenance-graph" /* The real graph's geometry. The widths are imported; the rest is the private arithmetic of `provenance-graph.ts`, mirrored rather than exported — the ghost @@ -25,22 +25,19 @@ import { ACTION_WIDTH, LENS_WIDTH, SPINE_WIDTH } from "./provenance-graph" const GUTTER = 52 const SPINE_H = 128 const SPINE_H_LIVE = 148 -const LENS_H = 64 -const LENS_GAP = 8 const ACTION_H = 76 const ACTION_GAP = 8 /** Column headings ride 20px above their column's top edge. */ const HEADING_OFFSET = 20 /** - * Six columns on 198px origins — issue, incident, investigation, the lens fan, - * the verdict, the proposed actions — which is the widest chain the real builder - * emits and lands on its 1270 frame. + * Five columns on 198px origins — issue, incident, investigation, the verdict, + * the proposed actions — which is the widest chain the real builder emits. */ -const COLUMN_X = [0, 1, 2, 3, 4, 5].map((i) => i * (SPINE_WIDTH + GUTTER)) -const GRAPH_W = COLUMN_X[5] + ACTION_WIDTH +const COLUMN_X = [0, 1, 2, 3, 4].map((i) => i * (SPINE_WIDTH + GUTTER)) +const GRAPH_W = COLUMN_X[4] + ACTION_WIDTH /** The tallest column sets the frame; every other column centres against it. */ -const GRAPH_H = LENS_H * 3 + LENS_GAP * 2 +const GRAPH_H = Math.max(SPINE_H_LIVE, ACTION_H * 2 + ACTION_GAP) const top = (height: number) => (GRAPH_H - height) / 2 @@ -56,16 +53,9 @@ const spineCard = (column: number, h: number): Card => ({ /** The three causal steps: what happened, where, and the run itself. */ const SPINE: Array = [spineCard(0, SPINE_H), spineCard(1, SPINE_H), spineCard(2, SPINE_H_LIVE)] -const LENSES: Array = [0, 1, 2].map((i) => ({ - x: COLUMN_X[3], - y: i * (LENS_H + LENS_GAP), - w: LENS_WIDTH, - h: LENS_H, - r: 6, -})) -const VERDICT: Card = spineCard(4, SPINE_H) +const VERDICT: Card = spineCard(3, SPINE_H) const ACTIONS: Array = [0, 1].map((i) => ({ - x: COLUMN_X[5], + x: COLUMN_X[4], y: top(ACTION_H * 2 + ACTION_GAP) + i * (ACTION_H + ACTION_GAP), w: ACTION_WIDTH, h: ACTION_H, @@ -98,9 +88,8 @@ function stepPath(a: { x: number; y: number }, b: { x: number; y: number }) { const STRANDS: Array<{ d: string; dash: string }> = [ { d: stepPath(right(SPINE[0]), left(SPINE[1])), dash: "3 3" }, { d: stepPath(right(SPINE[1]), left(SPINE[2])), dash: "3 3" }, - // Fan out into the lanes, merge back into the verdict, then the roadmap hop. - ...LENSES.map((lens) => ({ d: stepPath(right(SPINE[2]), left(lens)), dash: "3 3" })), - ...LENSES.map((lens) => ({ d: stepPath(right(lens), left(VERDICT)), dash: "3 3" })), + // Into the verdict, then the roadmap hop. + { d: stepPath(right(SPINE[2]), left(VERDICT)), dash: "3 3" }, ...ACTIONS.map((action) => ({ d: stepPath(right(VERDICT), left(action)), dash: "4 4" })), ] @@ -111,7 +100,6 @@ const PORTS = [ right(SPINE[1]), left(SPINE[2]), right(SPINE[2]), - ...LENSES.flatMap((c) => [left(c), right(c)]), left(VERDICT), right(VERDICT), ...ACTIONS.map((c) => left(c)), @@ -176,29 +164,6 @@ function SpineGhost({ card, delay }: { card: Card; delay: number }) { ) } -/** A lens lane: title, then the state row — glyph, word, elapsed. */ -function LensGhost({ card, delay }: { card: Card; delay: number }) { - const { x, y, w, h } = card - return ( - - - - - - - - ) -} - /** A proposed action: the fixed ordinal/glyph gutter, two text rows, a chip. */ function ActionGhost({ card, delay }: { card: Card; delay: number }) { const { x, y, w, h } = card @@ -233,9 +198,8 @@ function ActionGhost({ card, delay }: { card: Card; delay: number }) { export function ProvenanceCanvasLoading() { const cards = [ ...SPINE.map((card, i) => ({ kind: "spine" as const, card, delay: i * 0.08 })), - ...LENSES.map((card, i) => ({ kind: "lens" as const, card, delay: 0.26 + i * 0.07 })), - { kind: "spine" as const, card: VERDICT, delay: 0.5 }, - ...ACTIONS.map((card, i) => ({ kind: "action" as const, card, delay: 0.58 + i * 0.07 })), + { kind: "spine" as const, card: VERDICT, delay: 0.3 }, + ...ACTIONS.map((card, i) => ({ kind: "action" as const, card, delay: 0.4 + i * 0.07 })), ] return ( @@ -267,9 +231,8 @@ export function ProvenanceCanvasLoading() { aria-label="Loading the provenance graph" fill="none" > - {/* Column headings — the fan and the actions column each carry one. */} - - + {/* Column heading — the actions column carries one. */} + {STRANDS.map((strand, i) => ( kind === "spine" ? ( - ) : kind === "lens" ? ( - ) : ( ), diff --git a/apps/web/src/components/investigations/hypotheses-tab.tsx b/apps/web/src/components/investigations/hypotheses-tab.tsx deleted file mode 100644 index 3b0309451..000000000 --- a/apps/web/src/components/investigations/hypotheses-tab.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import type { V2Investigation } from "@maple/domain/http/v2" -import { cn } from "@maple/ui/lib/utils" - -import { CauseRecap } from "./cause-recap" -import { ConfidenceMeter } from "./confidence-meter" -import type { LensVerdict } from "@maple/domain/http" -import { lensCopy } from "./lens-catalogue" -import { lensTally } from "./lens-derive" - -const VERDICT_LABEL: Record = { - promoted: "Promoted", - merged: "Merged", - ruled_out: "Ruled out", - rejected: "Rejected", - pending: "Running", -} satisfies Record - -const VERDICT_TONE: Record = { - promoted: "bg-success/12 text-success", - merged: "bg-muted text-muted-foreground", - ruled_out: "bg-muted text-muted-foreground", - rejected: "bg-destructive/12 text-destructive", - pending: "bg-primary/10 text-primary", -} satisfies Record - -const VERDICT_DOT: Record = { - promoted: "bg-success", - merged: "bg-muted-foreground/60", - ruled_out: "bg-muted-foreground/40", - rejected: "bg-destructive", - pending: "bg-primary animate-pulse", -} satisfies Record - -/** - * The trust payload. A promoted cause on its own asks to be believed; this table - * shows the obvious alternative was dispatched, what it claimed, and the one-line - * reason it lost. A struck-through claim was *made and then rejected* — which is - * a stronger statement than never having been considered. - * - * Never rendered at a fan-out of one: with no rivals there is nothing to rank, - * and `InvestigationTabs` drops the tab entirely. - */ -export function HypothesesTab({ investigation }: { investigation: V2Investigation }) { - const lenses = investigation.lens_runs - const tally = lensTally(lenses) - const size = investigation.fanout.size - - return ( -
    - -
    -
    -
    -

    - Hypotheses considered -

    - {summarise(tally)} -
    - - Fan-out scaled to {size} {size === 1 ? "lens" : "lenses"} - -
    -
      - {lenses.map((entry) => ( -
    • - - - - {VERDICT_LABEL[entry.verdict as LensVerdict] ?? entry.verdict} - - - - {lensCopy(entry).name} - - {entry.elapsedSeconds === null - ? "queued" - : `${entry.elapsedSeconds.toFixed(1)}s · ${entry.toolCount} tools`} - - -
      - {entry.claim ? ( -

      - {entry.claim} -

      - ) : ( -

      - {entry.progressNote ?? "No candidate yet"} -

      - )} - {entry.reason ? ( -

      - Validator: {entry.reason} -

      - ) : null} -
      - - - -
    • - ))} -
    -
    -
    - ) -} - -const summarise = (tally: ReturnType): string => { - const parts = [`${tally.total} lenses dispatched`] - if (tally.promoted > 0) parts.push(`${tally.promoted} promoted`) - if (tally.merged > 0) parts.push(`${tally.merged} merged`) - if (tally.ruledOut > 0) parts.push(`${tally.ruledOut} ruled out`) - if (tally.rejected > 0) parts.push(`${tally.rejected} rejected`) - if (parts.length === 1) parts.push(`${tally.reported} reported`) - return parts.join(" · ") -} diff --git a/apps/web/src/components/investigations/investigation-table.tsx b/apps/web/src/components/investigations/investigation-table.tsx index df70a3e63..a082443ee 100644 --- a/apps/web/src/components/investigations/investigation-table.tsx +++ b/apps/web/src/components/investigations/investigation-table.tsx @@ -7,7 +7,6 @@ import { formatRelativeTime, toEpochMs } from "@maple/ui/lib/time-format" import { SeverityBadge } from "@/components/errors/severity-badge" import { ConfidenceMeter } from "./confidence-meter" -import { hasFanout, lensTally } from "./lens-derive" import { investigationFinding, investigationHeadline, @@ -75,7 +74,7 @@ function InvestigationRow({ investigation }: { investigation: V2Investigation }) ) : null} - + @@ -97,33 +96,13 @@ function InvestigationRow({ investigation }: { investigation: V2Investigation }) ) } -/** - * The row's second line. A running pass says how far through the fan-out it is - * rather than a generic "gathering evidence…" — that is the one thing someone - * watching a live investigation actually wants from a list. - */ -function RowFinding({ - investigation, - finding, -}: { - investigation: V2Investigation - finding: ReturnType -}) { - if (finding.kind === "pending" && hasFanout(investigation)) { - const tally = lensTally(investigation.lens_runs) - // `settled`, not `reported`: a crashed lens is a terminal `no_finding` lane - // and the run moves on, so counting only reporters leaves the row claiming - // lenses are still out when none are. And the validator is only blocked - // while that is true — once every lane has settled it is the one running. - const waiting = tally.settled < tally.total +/** The row's second line: the finding, or that the agent is still at work. */ +function RowFinding({ finding }: { finding: ReturnType }) { + if (finding.kind === "pending") { return ( - - {waiting - ? `${tally.settled} of ${tally.total} lenses reported · validator blocked` - : `All ${tally.total} lenses reported · ranking`} - + Gathering evidence ) } @@ -143,9 +122,6 @@ function RowFinding({ )} title={finding.text} > - {finding.kind === "pending" ? ( - - ) : null} {finding.text} ) diff --git a/apps/web/src/components/investigations/investigation-tabs.tsx b/apps/web/src/components/investigations/investigation-tabs.tsx index 956dc7ade..2659ceec8 100644 --- a/apps/web/src/components/investigations/investigation-tabs.tsx +++ b/apps/web/src/components/investigations/investigation-tabs.tsx @@ -2,9 +2,7 @@ import { Link } from "@tanstack/react-router" import type { V2Investigation } from "@maple/domain/http/v2" import { cn } from "@maple/ui/lib/utils" -import { hasFanout } from "./lens-derive" - -export const INVESTIGATION_TABS = ["overview", "evidence", "hypotheses", "chat", "transcript"] as const +export const INVESTIGATION_TABS = ["overview", "evidence", "chat", "transcript"] as const export type InvestigationTab = (typeof INVESTIGATION_TABS)[number] @@ -23,19 +21,12 @@ export function InvestigationTabs({ active: InvestigationTab }) { const evidenceCount = investigation.report?.evidence.length ?? 0 - // At a fan-out of one there are no rivals to rank, so the section would be an - // empty table with a heading — the design drops the tab entirely. - const showHypotheses = hasFanout(investigation) - const hypothesesCount = investigation.lens_runs.length const tabs: ReadonlyArray<{ value: InvestigationTab; label: string; count?: number }> = [ { value: "overview", label: "Overview" }, ...(evidenceCount > 0 ? [{ value: "evidence" as const, label: "Evidence", count: evidenceCount }] : []), - ...(showHypotheses - ? [{ value: "hypotheses" as const, label: "Hypotheses", count: hypothesesCount }] - : []), { value: "chat", label: "Chat" }, { value: "transcript", label: "Transcript" }, ] diff --git a/apps/web/src/components/investigations/investigation-view.tsx b/apps/web/src/components/investigations/investigation-view.tsx index 8c588d265..07f40adf8 100644 --- a/apps/web/src/components/investigations/investigation-view.tsx +++ b/apps/web/src/components/investigations/investigation-view.tsx @@ -13,7 +13,6 @@ import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { EvidenceTab } from "./evidence-tab" import { ProvenanceCanvas } from "./flow/provenance-canvas" import { FollowUpComposer } from "./follow-up-composer" -import { HypothesesTab } from "./hypotheses-tab" import { ImpactStrip } from "./impact-strip" import { investigationHeadline } from "./investigation-display" import { InvestigationHeader } from "./investigation-header" @@ -265,8 +264,6 @@ export function InvestigationView({
    {tab === "evidence" ? ( - ) : tab === "hypotheses" ? ( - ) : ( <> {/* diff --git a/apps/web/src/components/investigations/lens-catalogue.ts b/apps/web/src/components/investigations/lens-catalogue.ts deleted file mode 100644 index a51aec6d2..000000000 --- a/apps/web/src/components/investigations/lens-catalogue.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Display copy for a lane, and the seed catalogue's static fallback. - * - * This file used to be the only place the web knew what a lens was called, - * because the catalogue was fixed and a copy edit shouldn't need an API deploy. - * Hypotheses are planner-written now, so the *name* is data: no static table can - * say "the 14:02 payments-api rollout". The server sends `lens_name` and - * `lens_question` and they win. - * - * What stays here is the fallback, and it earns its place twice — for lanes - * written before the planner (whose `lensId` still names a catalogue entry) and - * for the seed hypotheses a run falls back to when the planner produces nothing - * usable. - */ -import type { SeedLensId } from "@maple/domain/http" - -export interface LensCopy { - readonly name: string - /** What this lens is actually asking — shown as the catalogue's one-liner. */ - readonly question: string - /** The rail's short label for the same lens, phrased as a check. */ - readonly checkLabel: string -} - -export const LENS_COPY: Record = { - deploy_correlation: { - name: "Deploy correlation", - question: "What shipped before the window, and does the onset line up", - checkLabel: "Deploy in the window", - }, - downstream_dependency: { - name: "Downstream dependency", - question: "Is a callee actually degraded, or only being blamed", - checkLabel: "Downstream latency", - }, - resource_saturation: { - name: "Resource saturation", - question: "Pools, queues, memory, connections at a ceiling", - checkLabel: "Connection pool headroom", - }, - traffic_shape: { - name: "Traffic shape", - question: "Volume and mix against the 7-day baseline for that hour", - checkLabel: "Request volume", - }, -} satisfies Record - -/** - * The subset of a lane this needs. Structural rather than the full wire row, so a - * test can pass three fields and the seed catalogue's own entries can be checked - * against it. Field names are the *decoded* ones — `lens_name` on the wire - * arrives here as `name`. - */ -export interface LensCopySource { - readonly lensId: string - readonly name?: string | null - readonly question?: string | null -} - -/** - * Server copy where there is any, the seed catalogue where the id names one, and - * a humanised id as the last resort. - * - * Takes the lane rather than its id, which is the whole change: a planner-written - * name only exists on the row. The final fallback stays because `lens_runs` is - * annotated on the wire as an evolving shape — an unknown id must degrade to a - * readable label, never blank the page. - */ -export const lensCopy = (run: LensCopySource): LensCopy => { - if (run.name) { - return { - name: run.name, - question: run.question ?? "", - // The planner's name already reads as the thing being checked, so there is - // no shorter phrasing to reach for — a second server field for the rail - // would be one more string per lane for a distinction nobody sees. - checkLabel: run.name, - } - } - const seeded = LENS_COPY[run.lensId as SeedLensId] - if (seeded) return seeded - return { name: humanise(run.lensId), question: "", checkLabel: humanise(run.lensId) } -} - -/** `cache_pressure` → `Cache pressure`. Better than printing a raw token. */ -const humanise = (id: string): string => id.replace(/_/g, " ").replace(/^./, (first) => first.toUpperCase()) diff --git a/apps/web/src/components/investigations/lens-derive.test.ts b/apps/web/src/components/investigations/lens-derive.test.ts deleted file mode 100644 index 26cc4671c..000000000 --- a/apps/web/src/components/investigations/lens-derive.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { V2Investigation } from "@maple/domain/http/v2" -import { describe, expect, it } from "vitest" - -import { checksHeld, hasFanout, lensChecks, lensNodeState, lensTally, type LensRun } from "./lens-derive" - -const lens = (overrides: Partial = {}): LensRun => - ({ - lensId: "deploy_correlation", - status: "reported", - verdict: "ruled_out", - claim: "a deploy landed before the onset", - reason: "no deploy landed inside the window", - progressNote: null, - confidence: "medium", - toolCount: 3, - elapsedSeconds: 12.6, - name: null, - question: null, - priority: null, - deadlineHit: false, - ...overrides, - }) as LensRun - -const make = (overrides: Partial = {}): V2Investigation => - ({ - id: "inv_1", - status: "diagnosed", - lens_runs: [], - validator: null, - fanout: { state: "none", size: 1 }, - ...overrides, - }) as V2Investigation - -describe("hasFanout", () => { - /** - * The collision the split exists for: the sizing table says 5 because it is an - * alert, the routing gate declined because it arrived automatically at medium - * severity. Reading `fanout.size` here would render a Hypotheses tab over an - * empty array. - */ - it("keys off dispatched lenses, not the computed size", () => { - expect(hasFanout(make({ lens_runs: [], fanout: { state: "none", size: 5 } } as never))).toBe(false) - expect(hasFanout(make({ lens_runs: [lens()] } as never))).toBe(true) - }) -}) - -describe("lensChecks", () => { - it("runs one check per dispatched lens, in the order given", () => { - const lenses = [lens({ lensId: "deploy_correlation" }), lens({ lensId: "traffic_shape" })] - expect(lensChecks(lenses).map((check) => check.key)).toEqual(["deploy_correlation", "traffic_shape"]) - }) - - it("holds exactly the lenses the validator kept", () => { - const lenses = [ - lens({ verdict: "promoted" }), - lens({ lensId: "traffic_shape", verdict: "merged" }), - lens({ lensId: "config_flags", verdict: "ruled_out" }), - ] - expect(checksHeld(lensChecks(lenses))).toBe(2) - }) - - /** - * The regression the placeholder module shipped with: the rail generated its - * own verdicts, so a run whose validator rejected everything still showed - * green ticks a few hundred pixels from "none of them held up". - */ - it("holds nothing when the validator promoted nothing", () => { - const lenses = [lens({ verdict: "rejected" }), lens({ lensId: "traffic_shape", verdict: "rejected" })] - expect(checksHeld(lensChecks(lenses))).toBe(0) - }) - - /** - * While the validator is reading the candidates every lane is `pending`. - * Rendering those as "failed" put an ✗ and a "0 of 5 held" header beside a - * card saying the validator was still blocked. - */ - it("does not rule against a lens the validator has not ranked yet", () => { - const checks = lensChecks([ - lens({ verdict: "pending" }), - lens({ lensId: "traffic_shape", verdict: "pending" }), - ]) - expect(checks.map((check) => check.state)).toEqual(["pending", "pending"]) - expect(checksHeld(checks)).toBe(0) - // It shows what the lens said, not a verdict nobody reached. - expect(checks[0]!.result).toBe("a deploy landed before the onset") - }) - - it("quotes the validator's own sentence rather than a canned one", () => { - const checks = lensChecks([lens({ reason: "callee percentiles stayed flat across the window" })]) - expect(checks[0]!.result).toBe("callee percentiles stayed flat across the window") - }) - - it("maps in-flight lanes to their live states", () => { - const checks = lensChecks([ - lens({ status: "checking", progressNote: "comparing percentiles…" }), - lens({ lensId: "traffic_shape", status: "queued" }), - lens({ lensId: "config_flags", status: "no_finding", reason: "ran out of budget" }), - ]) - expect(checks.map((check) => check.state)).toEqual(["checking", "queued", "skipped"]) - expect(checks[0]!.result).toBe("comparing percentiles…") - }) - - it("never renders an empty result line", () => { - for (const status of ["queued", "checking", "reported", "no_finding"] as const) { - for (const check of lensChecks([ - lens({ status, reason: null, claim: null, progressNote: null }), - ])) { - expect(check.result.length).toBeGreaterThan(0) - } - } - }) -}) - -/** - * `lens_runs` is documented as an evolving shape. That promise was empty while - * the tokens were closed literals: a server that learned a sixth lens failed the - * decode for every deployed client and blanked the page. These assert the client - * survives one. - */ -describe("unknown catalogue tokens", () => { - it("renders a lens it has never heard of", () => { - const checks = lensChecks([lens({ lensId: "cache_pressure" } as never)]) - expect(checks[0]!.label).toBe("Cache pressure") - expect(checks[0]!.result).toBeTruthy() - }) - - it("does not claim an unknown verdict held", () => { - const checks = lensChecks([lens({ verdict: "deferred" } as never)]) - expect(checksHeld(checks)).toBe(0) - }) -}) - -describe("lensTally", () => { - it("splits the verdicts", () => { - const tally = lensTally([ - lens({ verdict: "promoted" }), - lens({ verdict: "merged" }), - lens({ verdict: "ruled_out" }), - lens({ verdict: "rejected", status: "no_finding" }), - ]) - expect(tally).toEqual({ - total: 4, - reported: 3, - // The `no_finding` lane never reported a candidate, but it is terminal — - // it is what a crashed lens becomes, and the run proceeds past it. - settled: 4, - promoted: 1, - merged: 1, - ruledOut: 1, - rejected: 1, - }) - }) -}) - -describe("lensNodeState", () => { - it("always states the verdict in words, never in colour alone", () => { - const runs = [ - lens({ status: "queued" }), - lens({ status: "checking" }), - lens({ verdict: "promoted" }), - lens({ verdict: "merged" }), - lens({ verdict: "ruled_out" }), - lens({ verdict: "pending" }), - lens({ deadlineHit: true }), - lens({ status: "no_finding" }), - ] - for (const state of runs.map(lensNodeState)) { - expect(state.word.length).toBeGreaterThan(0) - } - }) - - /** - * A lane that ran out of clock says so before it says anything about a - * verdict — the workflow writes `ruled_out` on lanes it never heard back from, - * and rendering that is a claim the run did not make. - */ - it("reports a timed-out lane as timed out, not as ruled out", () => { - expect(lensNodeState(lens({ deadlineHit: true, verdict: "ruled_out" }))).toMatchObject({ - word: "DEADLINE HIT", - tone: "warning", - }) - }) - - it("agrees with the checks rail about which lanes held", () => { - const runs = [ - lens({ verdict: "promoted" }), - lens({ verdict: "merged" }), - lens({ verdict: "rejected" }), - ] - const heldWords = runs.map((run) => lensNodeState(run).word).filter((word) => word !== "RULED OUT") - expect(heldWords).toHaveLength(checksHeld(lensChecks(runs))) - }) - - it("does not strike a lane the validator has not ranked yet", () => { - expect(lensNodeState(lens({ verdict: "pending" }))).toMatchObject({ - word: "REPORTED", - struck: false, - }) - }) -}) diff --git a/apps/web/src/components/investigations/lens-derive.ts b/apps/web/src/components/investigations/lens-derive.ts deleted file mode 100644 index bcc2fb204..000000000 --- a/apps/web/src/components/investigations/lens-derive.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Pure derivations over the real `lens_runs` array. - * - * These are what survived `fanout-placeholder.ts`: the tally, the run-spine - * segment and the checks panel were always derivations, they just used to derive - * from invented data. Nothing here invents anything — every value traces back to - * a lens row the workflow wrote. - */ -import type { V2Investigation } from "@maple/domain/http/v2" -import { lensCopy } from "./lens-catalogue" - -export type LensRun = V2Investigation["lens_runs"][number] - -/** - * Did this run fan out? - * - * Keyed off dispatched lenses, NOT off `fanout.size`. The sizing table and the - * routing gate are different questions: an automatic medium-severity alert - * computes a size of 5 and still runs single-pass, and reading the size here - * would render a Hypotheses tab over an empty array. - */ -export const hasFanout = (investigation: V2Investigation): boolean => investigation.lens_runs.length > 0 - -export interface LensTally { - readonly total: number - /** Lenses that put a candidate forward. A `no_finding` lane is NOT one. */ - readonly reported: number - /** - * Lenses that will not change again — reported *or* no_finding. - * - * This, not `reported`, is what "is the fan-out still running?" means. A lens - * that crashed is terminal: the workflow turns it into a `no_finding` lane and - * proceeds to validate. Gating progress on `reported` wedges the run spine on - * "4 of 5 reported" forever while the board shows a validated diagnosis. - */ - readonly settled: number - readonly promoted: number - readonly merged: number - readonly ruledOut: number - readonly rejected: number -} - -export const lensTally = (lenses: ReadonlyArray): LensTally => ({ - total: lenses.length, - reported: lenses.filter((lens) => lens.status === "reported").length, - settled: lenses.filter((lens) => lens.status === "reported" || lens.status === "no_finding").length, - promoted: lenses.filter((lens) => lens.verdict === "promoted").length, - merged: lenses.filter((lens) => lens.verdict === "merged").length, - ruledOut: lenses.filter((lens) => lens.verdict === "ruled_out").length, - rejected: lenses.filter((lens) => lens.verdict === "rejected").length, -}) - -/* ------------------------------------------------------------------------------------------------- - * Checks - * -----------------------------------------------------------------------------------------------*/ - -/** - * `pending` is a lens that reported but has not been ranked yet. It is NOT - * `failed`: rendering an un-ranked candidate as "Did not hold" tells the reader - * the validator ruled against it while the validator is still reading it. - */ -export type CheckState = "held" | "failed" | "pending" | "checking" | "queued" | "skipped" - -export interface LensCheck { - readonly key: string - readonly label: string - /** The terse result line under the label — the whole point of the panel. */ - readonly result: string - readonly state: CheckState -} - -/** - * The rail's lead: one line per dispatched lens. - * - * Derived from the lanes rather than generated alongside them, which is what - * keeps the rail and the board from disagreeing — a validator that rejected - * everything cannot leave three green ticks standing 300px away from "none of - * them held up". The result line is the validator's own sentence where there is - * one, so the panel quotes the run instead of paraphrasing it. - */ -export const lensChecks = (lenses: ReadonlyArray): ReadonlyArray => - lenses.map((lens) => { - const label = lensCopy(lens).checkLabel - const base = { key: lens.lensId, label } - switch (lens.status) { - case "checking": - return { ...base, result: lens.progressNote ?? "checking…", state: "checking" as const } - case "queued": - return { ...base, result: "queued", state: "queued" as const } - case "no_finding": - return { - ...base, - result: lens.reason ?? "not checked — the lens reached no finding", - state: "skipped" as const, - } - default: { - if (lens.verdict === "pending") { - return { - ...base, - result: lens.claim ?? "reported — awaiting the validator", - state: "pending" as const, - } - } - const held = lens.verdict === "promoted" || lens.verdict === "merged" - return { - ...base, - // A failed check is a finding, not a gap: "callee percentiles flat" - // is exactly what rules the obvious alternative out. - result: lens.reason ?? lens.claim ?? (held ? "held" : "did not hold"), - state: held ? ("held" as const) : ("failed" as const), - } - } - } - }) - -export const checksHeld = (checks: ReadonlyArray): number => - checks.filter((check) => check.state === "held").length - -/* ------------------------------------------------------------------------------------------------- - * Lens nodes — the provenance canvas's fan - * -----------------------------------------------------------------------------------------------*/ - -export type LensTone = "muted" | "primary" | "success" | "info" | "warning" | "destructive" -/** - * `queued` and `reported` used to be one `pending` icon, which meant both wore a - * loader — motionless, because only a running lane is allowed to move. A stopped - * spinner is the one glyph that says nothing at all: it reads as a component that - * failed to start rather than as a state. - * - * They are also not the same state. A queued lane has not begun; a reported one - * has finished its work and is waiting on someone else's judgement. Two waits, - * two glyphs. - */ -export type LensIcon = "queued" | "running" | "reported" | "confirmed" | "ruledOut" | "deadline" | "failed" - -export interface LensNodeState { - /** Always present. The word carries the state; colour only reinforces it. */ - readonly word: string - readonly tone: LensTone - readonly icon: LensIcon - /** A ruled-out lane strikes its title — the claim was considered and rejected. */ - readonly struck: boolean - /** A queued lane hasn't started, so its border is dashed rather than solid. */ - readonly dashed: boolean -} - -/** - * The same status/verdict switch `lensChecks` walks, resolved to a node's badge - * instead of a rail row. - * - * It lives beside `lensChecks` rather than in the canvas for the reason that - * function exists at all: the fan and the checks rail describe the same lanes - * 600px apart, and two independent mappings are how you end up with a green tick - * next to a node reading "ruled out". - */ -export const lensNodeState = (lens: LensRun): LensNodeState => { - const base = { struck: false, dashed: false } - switch (lens.status) { - case "queued": - return { ...base, word: "PENDING", tone: "muted", icon: "queued", dashed: true } - case "checking": - return { ...base, word: "RUNNING", tone: "primary", icon: "running" } - default: { - // A lane that ran out of clock says so before it says anything about a - // verdict — "ruled out" on a lane that never finished is a claim the run - // did not make. - if (lens.deadlineHit) { - return { ...base, word: "DEADLINE HIT", tone: "warning", icon: "deadline" } - } - if (lens.status === "no_finding") { - return { ...base, word: "NO FINDING", tone: "muted", icon: "failed" } - } - if (lens.verdict === "pending") { - return { ...base, word: "REPORTED", tone: "muted", icon: "reported" } - } - if (lens.verdict === "promoted") { - return { ...base, word: "CONFIRMED", tone: "success", icon: "confirmed" } - } - if (lens.verdict === "merged") { - return { ...base, word: "MERGED", tone: "info", icon: "confirmed" } - } - return { ...base, word: "RULED OUT", tone: "muted", icon: "ruledOut", struck: true } - } - } -} diff --git a/apps/web/src/components/investigations/verdict-card.tsx b/apps/web/src/components/investigations/verdict-card.tsx index f61254d80..9608e8d6d 100644 --- a/apps/web/src/components/investigations/verdict-card.tsx +++ b/apps/web/src/components/investigations/verdict-card.tsx @@ -4,12 +4,10 @@ import { cn } from "@maple/ui/lib/utils" import { toEpochMs } from "@maple/ui/lib/time-format" import { SEVERITY_LABEL } from "@/components/errors/severity-badge" -import { CheckIcon, CircleQuestionIcon, CircleXmarkIcon } from "@/components/icons" +import { CircleQuestionIcon, CircleXmarkIcon } from "@/components/icons" import { useTickingNow } from "@/hooks/use-ticking-now" import { type Elapsed, splitDuration } from "./investigation-display" import { ConfidenceMeter } from "./confidence-meter" -import { lensCopy } from "./lens-catalogue" -import { type LensRun, hasFanout, lensTally } from "./lens-derive" /** * What the investigation concluded, or how far it has got trying. One card, four @@ -122,9 +120,6 @@ function Eyebrow({ children, tone }: { children: ReactNode; tone: string }) { function DiagnosedVerdict({ investigation }: { investigation: V2Investigation }) { const report = investigation.report - const promoted = hasFanout(investigation) - ? investigation.lens_runs.find((lens) => lens.verdict === "promoted") - : undefined if (!report) { return ( @@ -177,30 +172,7 @@ function DiagnosedVerdict({ investigation }: { investigation: V2Investigation }) } > - - Suspected cause - {promoted ? ( - <> - - · - - - - Validated - - {/* - Quoted verbatim, not lowercased into a sentence. That read fine - against a four-word catalogue label ("the deploy correlation - lens"); a planner writes "The 14:02 payments-api rollout", and - "promoted from the the 14:02 payments-api rollout lens" is not a - sentence at all. - */} - - promoted from “{lensCopy(promoted).name}” - - - ) : null} - + Suspected cause

    {report.suspectedCause}

    @@ -222,15 +194,7 @@ const SEVERITY_TEXT_TONE: Record = { * Investigating * -----------------------------------------------------------------------------------------------*/ -const COUNT_WORD = ["No", "One", "Two", "Three", "Four", "Five"] as const -const countWord = (n: number) => COUNT_WORD[n] ?? String(n) - function InvestigatingVerdict({ investigation }: { investigation: V2Investigation }) { - const lenses = investigation.lens_runs - const tally = lensTally(lenses) - const validator = investigation.validator - const fanned = hasFanout(investigation) - return ( - {fanned ? ( - - - {tally.reported} of {tally.total} - - - ) : null} - {validator ? ( - - Blocked - - ) : null} Pending @@ -262,27 +214,14 @@ function InvestigatingVerdict({ investigation }: { investigation: V2Investigatio Investigating - {fanned ? ( - <> - - · - - {tally.total} lenses in flight - - ) : null}

    - {fanned - ? `${countWord(tally.total)} agents are attacking this from different angles. ${countWord(tally.reported)} ${tally.reported === 1 ? "has" : "have"} reported.` - : "Maple is gathering evidence."} + Maple is gathering evidence.

    - {fanned ? ( - - ) : ( -

    - One agent is working this question. The transcript shows what it is doing as it goes. -

    - )} +

    + One agent is working this question: reading the traces, logs and metrics around it and testing + the likely explanations. The transcript shows what it is doing as it goes. +

    ) } @@ -292,10 +231,6 @@ function InvestigatingVerdict({ investigation }: { investigation: V2Investigatio * -----------------------------------------------------------------------------------------------*/ function FailedVerdict({ investigation }: { investigation: V2Investigation }) { - const lenses = investigation.lens_runs - const tally = lensTally(lenses) - const fanned = hasFanout(investigation) - const validator = investigation.validator // From `started_at`, not `created_at`: a restart re-stamps the former, and // measuring from the latter reported a 20-day-old investigation as a // 480-hour run. @@ -303,12 +238,6 @@ function FailedVerdict({ investigation }: { investigation: V2Investigation }) { investigation.started_at ?? investigation.created_at, investigation.updated_at, ) - // A fan-out can fail two ways, and they are not the same claim. The validator - // ranking every candidate down is a *finding*. Dying before the validator ran - // — a stalled workflow swept by the timeout — is not, and saying "rejected - // every candidate" there states a ruling nobody made, beside a validator lane - // that still reads "blocked" and a `diagnosis_timeout` error box. - const rejectedAll = fanned && validator?.status === "rejected_all" return ( — )} - {fanned ? ( - - - {tally.reported} of {tally.total} - - - ) : null} - - - {rejectedAll ? "Rejected all" : fanned ? "Never ran" : "None"} - - None } > - - No diagnosis - {rejectedAll ? ( - <> - - · - - Validator rejected every candidate - - ) : null} - + No diagnosis

    - {rejectedAll - ? `${countWord(tally.reported)} ${tally.reported === 1 ? "lens" : "lenses"} reported, and none of them held up` - : fanned - ? "The fan-out ended before the validator could rank it" - : "The pass ended without a diagnosis"} + The pass ended without a diagnosis

    - {rejectedAll - ? "The candidates contradicted each other, so Maple promoted nothing rather than guess. What each lens did gather is kept below — a retry re-runs the fan-out with a wider evidence budget." - : fanned - ? "Whatever the lenses gathered is kept below, but nothing ranked them. A retry re-runs the fan-out." - : "Nothing was promoted. Retry to run the pass again."} + Nothing was recorded. The transcript keeps whatever the agent gathered; retry to run the pass + again.

    {/* The raw error was on the wire and rendered nowhere but a toast. */} {investigation.error ? ( @@ -376,7 +276,6 @@ function FailedVerdict({ investigation }: { investigation: V2Investigation }) {
    ) : null} - {fanned ? : null} ) } @@ -385,7 +284,7 @@ function FailedVerdict({ investigation }: { investigation: V2Investigation }) { * Inconclusive * -----------------------------------------------------------------------------------------------*/ -/** Above this the lists collapse; the rest is one click away on the Hypotheses tab. */ +/** Above this the lists fold; the whole list is in the report on the Evidence tab. */ const PARTIAL_VISIBLE_MAX = 5 /** @@ -401,10 +300,6 @@ const PARTIAL_VISIBLE_MAX = 5 * `unchecked`. */ function InconclusiveVerdict({ investigation }: { investigation: V2Investigation }) { - const lenses = investigation.lens_runs - const tally = lensTally(lenses) - const validator = investigation.validator - const fanned = hasFanout(investigation) const report = investigation.report // From `started_at` for the same reason the failed card is: a restart // re-stamps it, and measuring from `created_at` reports a 20-day-old @@ -415,12 +310,8 @@ function InconclusiveVerdict({ investigation }: { investigation: V2Investigation ) const ruledOut = report?.ruledOut ?? [] const unchecked = report?.unchecked ?? [] - // Legacy rows backfilled to `inconclusive` have no report at all. The - // validator's note is the only sentence they carry. - const headline = - report?.suspectedCause ?? - validator?.note ?? - "No cause was established, and this run recorded no partial." + // Legacy rows backfilled to `inconclusive` have no report at all. + const headline = report?.suspectedCause ?? "No cause was established, and this run recorded no partial." return (
    )} - {fanned ? ( - - - {tally.reported} of {tally.total} - - - ) : null} - {/* Replaces the failed card's "Validation: Rejected all", which said - the same thing in a way that sounded like a defect. What was - eliminated is the run's actual output, so it gets the stat. */} + {/* What was eliminated is the run's actual output, so it gets the stat. */} {ruledOut.length} @@ -467,17 +349,7 @@ function InconclusiveVerdict({ investigation }: { investigation: V2Investigation · - Nothing promoted - {fanned ? ( - <> - - · - - - {tally.reported} of {tally.total} lenses reported - - - ) : null} + No cause established

    {headline} @@ -494,8 +366,7 @@ function InconclusiveVerdict({ investigation }: { investigation: V2Investigation icon={ } - // Not struck through. These are conclusions the run reached, which - // is the opposite of the dead lanes the failed card strikes out. + // Not struck through: these are conclusions the run reached. tone="text-foreground" /> ) : null} - - {fanned ? : null} ) } @@ -545,125 +414,12 @@ function PartialList({ ))} {hidden > 0 ? ( - +{hidden} more on the Hypotheses tab + +{hidden} more in the report ) : null} ) } -/* ------------------------------------------------------------------------------------------------- - * Lens lanes - * -----------------------------------------------------------------------------------------------*/ - -const LANE_DOT: Record = { - reported: "bg-success", - checking: "bg-primary animate-pulse", - queued: "border border-muted-foreground/40", - no_finding: "bg-muted-foreground/40", -} satisfies Record - -const LANE_NOTE: Record = { - reported: "reported a candidate", - checking: "checking", - queued: "queued", - no_finding: "no finding", -} satisfies Record - -/** - * One row per dispatched lens: where it got to, what it claimed, how long it - * took. On a failed run the claims are struck through — they were reported and - * then rejected, which is different from never having been made. - */ -function LensLanes({ - lenses, - validator, -}: { - lenses: ReadonlyArray - validator: V2Investigation["validator"] -}) { - // `flex-wrap` + a `min-w-*` floor on the claim: with the rail open and the - // stat column showing, a fixed three-column lane crushes the claim to a - // five-word-per-line ribbon. Below the floor the claim drops to its own line - // at full width instead. - return ( -
      - {lenses.map((entry) => ( -
    • - - - {lensCopy(entry).name} - - {entry.progressNote ?? LANE_NOTE[entry.status]} - - - - {entry.claim ?? entry.reason ?? "—"} - - - {entry.elapsedSeconds === null ? "—" : `${entry.elapsedSeconds.toFixed(1)}s`} - -
    • - ))} - {validator ? ( -
    • - - - - Validator - - - {validator.status === "blocked" - ? "blocked" - : validator.status === "rejected_all" - ? "rejected all" - : "ranked"} - - - {validator.note} - - {validator.elapsedSeconds === null ? "—" : `${validator.elapsedSeconds.toFixed(1)}s`} - -
    • - ) : null} -
    - ) -} - /* ------------------------------------------------------------------------------------------------- * Elapsed helpers * -----------------------------------------------------------------------------------------------*/ @@ -678,8 +434,7 @@ function elapsedBetween(from: string, to: string | null): Elapsed | null { /** * The one live number on the page. Its own component so the 1s tick re-renders - * four characters rather than the whole verdict card — the card holds the lens - * lanes, and rebuilding those every second is wasted work. + * four characters rather than the whole verdict card. */ function LiveElapsedStat({ from }: { from: string }) { const now = useTickingNow(true) diff --git a/apps/web/src/components/settings/ai-triage-settings-section.tsx b/apps/web/src/components/settings/ai-triage-settings-section.tsx index 1ba3f2ff7..01694ca77 100644 --- a/apps/web/src/components/settings/ai-triage-settings-section.tsx +++ b/apps/web/src/components/settings/ai-triage-settings-section.tsx @@ -190,7 +190,7 @@ export function AiTriageSettingsSection({ isAdmin, hasEntitlement }: AiTriageSet max={2000} disabled={isSaving} spent={current.usage.passes} - help="The spend ceiling. A planned investigation spends four to seven passes — planner, hypotheses, validator — so this is usually what stops triage first, whichever ceiling is reached first. Three tenths of it is reserved for high and critical incidents." + help="The spend ceiling. An investigation is one model pass, so this and the run ceiling count the same thing. Three tenths of it is reserved for high and critical incidents." onCommit={(parsed) => save( new AiTriageSettingsUpdateRequest({ maxPassesPerDay: parsed }), diff --git a/apps/web/src/hooks/use-investigation.ts b/apps/web/src/hooks/use-investigation.ts index 3c817eaed..cda4fd301 100644 --- a/apps/web/src/hooks/use-investigation.ts +++ b/apps/web/src/hooks/use-investigation.ts @@ -2,11 +2,9 @@ * The investigation detail page's data, live. * * This replaced `MapleApiV2AtomClient.query("investigations", "retrieve")` plus a - * 3s `useIntervalRefresh`. Two Electric shapes — the investigation row and its - * lens lanes — are recombined into the same `V2Investigation` the page already - * rendered, so every consumer below it is unchanged; what changed is that a lane - * reaching `checking`, a progress note, or the verdict landing now arrives when - * it happens rather than on the next tick. + * 3s `useIntervalRefresh`: the investigation row is an Electric shape rebuilt + * into the same `V2Investigation` the page already rendered, so the verdict + * landing arrives when it happens rather than on the next tick. */ import { useLiveQuery } from "@tanstack/react-db" import { useMemo } from "react" @@ -34,29 +32,24 @@ export function useInvestigation(investigationId: string): InvestigationSync { [orgKey, generation, investigationId], ) - const { data: rows, isLoading: rowLoading } = useLiveQuery({ + const { data: rows, isLoading } = useLiveQuery({ query: (q) => q.from({ i: collections.investigation }), }) - const { data: lenses, isLoading: lensLoading } = useLiveQuery({ - query: (q) => q.from({ l: collections.lensRuns }), - }) - const isLoading = rowLoading || lensLoading - const rowFailed = useCollectionLoadFailed(collections.investigation.id, rowLoading) - const lensFailed = useCollectionLoadFailed(collections.lensRuns.id, lensLoading) + const rowFailed = useCollectionLoadFailed(collections.investigation.id, isLoading) return useMemo((): InvestigationSync => { - if (rowFailed || lensFailed) return { state: "failed" } + if (rowFailed) return { state: "failed" } if (isLoading) return { state: "loading" } // The shape is already narrowed to this id server-side, so this is a // presence check rather than a lookup — but it stays a `find` so a stale // row from a previous scope could never be rendered as this one. const row = (rows ?? []).find((candidate) => candidate.id === investigationId) if (!row) return { state: "missing" } - const investigation = rowsToInvestigation(row, lenses ?? []) + const investigation = rowsToInvestigation(row) // A row that will not decode is a data problem, not a missing investigation // — "failed" keeps the retry affordance instead of claiming it is gone. if (investigation === null) return { state: "failed" } return { state: "ready", investigation } - }, [rows, lenses, isLoading, rowFailed, lensFailed, investigationId]) + }, [rows, isLoading, rowFailed, investigationId]) } diff --git a/apps/web/src/lab/node-catalogue/node-catalogue.test.ts b/apps/web/src/lab/node-catalogue/node-catalogue.test.ts index 3acf8d35e..e92d42f86 100644 --- a/apps/web/src/lab/node-catalogue/node-catalogue.test.ts +++ b/apps/web/src/lab/node-catalogue/node-catalogue.test.ts @@ -12,21 +12,18 @@ const catalogue = buildNodeCatalogue() * narrowing these assertions exist to have. */ const spines = catalogue.nodes.flatMap((node) => (node.type === "spine" ? [node.data] : [])) -const lenses = catalogue.nodes.flatMap((node) => (node.type === "lens" ? [node.data] : [])) const actions = catalogue.nodes.flatMap((node) => (node.type === "action" ? [node.data] : [])) /** * The studio is only worth having if it is exhaustive, and "exhaustive" is not a * property anyone re-checks by eye after adding a state. These assertions are the - * thing that fails when a new glyph, lens state or action kind lands without a + * thing that fails when a new glyph or action kind lands without a * cell — the gallery cannot silently stop covering the surface it documents. */ describe("node catalogue", () => { it("covers every node type the canvas registers", () => { const types = new Set(catalogue.nodes.map((node) => node.type)) - expect(types).toEqual( - new Set(["spine", "lens", "lensOverflow", "pendingVerdict", "action", "actionGhost", "heading"]), - ) + expect(types).toEqual(new Set(["spine", "pendingVerdict", "action", "actionGhost", "heading"])) }) it("covers every spine glyph", () => { @@ -40,31 +37,6 @@ describe("node catalogue", () => { expect(spines.some((data) => data.live === true && data.phase)).toBe(true) }) - it("covers every lens icon and every lens badge word", () => { - const states = lenses.map((data) => data.state) - expect(new Set(states.map((state) => state.icon))).toEqual( - new Set(["queued", "running", "reported", "confirmed", "ruledOut", "deadline", "failed"]), - ) - expect(new Set(states.map((state) => state.word))).toEqual( - new Set([ - "PENDING", - "RUNNING", - "DEADLINE HIT", - "NO FINDING", - "REPORTED", - "CONFIRMED", - "MERGED", - "RULED OUT", - ]), - ) - }) - - it("shows a running lens both with and without a progress note", () => { - const running = lenses.filter((data) => data.state.icon === "running") - expect(running.some((data) => data.progressNote !== null)).toBe(true) - expect(running.some((data) => data.progressNote === null)).toBe(true) - }) - it("covers every action kind", () => { const kinds = new Set(actions.map((data) => data.kind)) expect(kinds).toEqual(new Set(Object.keys(ACTION_GLYPH))) @@ -79,9 +51,7 @@ describe("node catalogue", () => { }) it("covers every edge kind, live and settled", () => { - expect(new Set(catalogue.edges.map((edge) => edge.kind))).toEqual( - new Set(["causal", "fan", "roadmap"]), - ) + expect(new Set(catalogue.edges.map((edge) => edge.kind))).toEqual(new Set(["causal", "roadmap"])) expect(catalogue.edges.some((edge) => edge.live === true)).toBe(true) expect(catalogue.edges.some((edge) => edge.live === undefined)).toBe(true) expect(catalogue.edges.some((edge) => edge.label)).toBe(true) diff --git a/apps/web/src/lab/node-catalogue/node-catalogue.ts b/apps/web/src/lab/node-catalogue/node-catalogue.ts index 991bf095e..9a0d59594 100644 --- a/apps/web/src/lab/node-catalogue/node-catalogue.ts +++ b/apps/web/src/lab/node-catalogue/node-catalogue.ts @@ -2,8 +2,8 @@ * Every state the provenance canvas can draw, on one canvas. * * The page only ever shows the handful of states a real investigation happens to - * be in, so most of this surface — eight lens states, ten action glyphs, three - * edge kinds doubled by `live` — has never been seen side by side. That is the + * be in, so most of this surface — ten action glyphs, two edge kinds doubled by + * `live` — has never been seen side by side. That is the * one view in which "does this read as one system?" is answerable at all, and * this builder is it. * @@ -17,16 +17,12 @@ * is: coverage over the state space is a fact about this array, and it should be * assertable without mounting a canvas. */ -import { lensChecks, lensNodeState, type LensRun } from "@/components/investigations/lens-derive" -import { lensCopy } from "@/components/investigations/lens-catalogue" import { ACTION_GLYPH } from "@/components/investigations/flow/flow-nodes" import type { ActionKind, ActionTarget } from "@/components/investigations/flow/action-target" import { ACTION_HEIGHT, ACTION_WIDTH, HEADING_HEIGHT, - LENS_HEIGHT, - LENS_WIDTH, SPINE_HEIGHT, SPINE_HEIGHT_CURRENT, SPINE_HEIGHT_LIVE, @@ -152,46 +148,6 @@ const spineCell = (g: Grid, caption: string, data: SpineNodeData) => { g.nodes.push({ id: g.nextId("spine"), type: "spine", position, width: SPINE_WIDTH, height, data }) } -/** - * A lens cell, built from a real `LensRun` rather than a hand-written - * `LensNodeState`. - * - * This is the whole reason the studio is trustworthy: the badge, tone, glyph, - * strike and dash all come out of `lensNodeState`, and the result line out of - * `lensChecks`, so a change to that state machine shows up here rather than - * quietly diverging from a frozen copy of its output. - */ -const lensCell = (g: Grid, caption: string, run: LensRun) => { - const position = g.reserve(caption, LENS_WIDTH, LENS_HEIGHT) - g.nodes.push({ - id: g.nextId("lens"), - type: "lens", - position, - width: LENS_WIDTH, - height: LENS_HEIGHT, - data: { - title: lensCopy(run).name, - question: run.question ?? "", - result: lensChecks([run])[0]?.result ?? "", - state: lensNodeState(run), - elapsed: run.elapsedSeconds == null ? null : `${run.elapsedSeconds.toFixed(1)}s`, - progressNote: run.progressNote, - }, - }) -} - -const overflowCell = (g: Grid, caption: string, hidden: number) => { - const position = g.reserve(caption, LENS_WIDTH, LENS_HEIGHT) - g.nodes.push({ - id: g.nextId("overflow"), - type: "lensOverflow", - position, - width: LENS_WIDTH, - height: LENS_HEIGHT, - data: { hidden }, - }) -} - const pendingVerdictCell = (g: Grid, caption: string, word: string, note: string | null) => { const position = g.reserve(caption, SPINE_WIDTH, SPINE_HEIGHT_TALL) g.nodes.push({ @@ -288,24 +244,6 @@ const edgeCell = ( * Fixtures * -----------------------------------------------------------------------------------------------*/ -const run = (overrides: Partial): LensRun => - ({ - lensId: "deploy_correlation", - status: "reported", - verdict: "ruled_out", - claim: "a deploy landed 40s before the onset", - reason: null, - progressNote: null, - confidence: "medium", - toolCount: 3, - elapsedSeconds: 9.4, - name: null, - question: null, - priority: null, - deadlineHit: false, - ...overrides, - }) as LensRun - const TONES: ReadonlyArray = ["muted", "primary", "success", "info", "warning", "destructive"] const GLYPHS: ReadonlyArray = ["issue", "check", "incident", "investigation", "verdict"] @@ -343,7 +281,7 @@ export function buildNodeCatalogue(): NodeCatalogue { glyph: "investigation", eyebrow: "INVESTIGATION", title: "Checkout timeouts", - note: "38s · 4 lenses", + note: "manual · 38s", current: true, at: AT, }) @@ -430,65 +368,11 @@ export function buildNodeCatalogue(): NodeCatalogue { }) } - /* --- lens ----------------------------------------------------------------- */ - - g.section("LENS · STATE") - lensCell(g, "PENDING", run({ status: "queued", verdict: "pending", elapsedSeconds: null })) - lensCell( - g, - "PENDING · NO ELAPSED", - run({ status: "queued", verdict: "pending", elapsedSeconds: null, name: "Traffic shape" }), - ) - lensCell( - g, - "RUNNING · WITH NOTE", - run({ - status: "checking", - verdict: "pending", - progressNote: "reading 4 traces", - elapsedSeconds: 3.2, - }), - ) - lensCell(g, "RUNNING · NO NOTE", run({ status: "checking", verdict: "pending", elapsedSeconds: 1.1 })) - lensCell(g, "DEADLINE HIT", run({ status: "reported", verdict: "pending", deadlineHit: true })) - lensCell( - g, - "NO FINDING", - run({ status: "no_finding", verdict: "pending", reason: "no deploy in the window" }), - ) - lensCell(g, "REPORTED", run({ status: "reported", verdict: "pending" })) - lensCell(g, "CONFIRMED", run({ status: "reported", verdict: "promoted", confidence: "high" })) - lensCell(g, "MERGED", run({ status: "reported", verdict: "merged" })) - lensCell(g, "RULED OUT", run({ status: "reported", verdict: "ruled_out" })) - lensCell(g, "REJECTED", run({ status: "reported", verdict: "rejected" })) - lensCell( - g, - "UNKNOWN LENS ID", - run({ lensId: "pool_exhaustion_payments_api", status: "reported", verdict: "promoted" }), - ) - lensCell( - g, - "OVERFLOW", - run({ - status: "checking", - verdict: "pending", - name: "A planner-written lane name long enough to truncate", - progressNote: "a progress note long enough to truncate on a 146px node", - elapsedSeconds: 128.75, - }), - ) - - g.section("LENS · OVERFLOW NODE") - overflowCell(g, "ONE HIDDEN", 1) - overflowCell(g, "TWO HIDDEN", 2) - overflowCell(g, "TWELVE HIDDEN", 12) - /* --- pending verdict ------------------------------------------------------ */ g.section("PENDING VERDICT") - pendingVerdictCell(g, "WITH NOTE", "AWAITING VERDICT", "4 candidates, ranking") + pendingVerdictCell(g, "WITH NOTE", "AWAITING VERDICT", "closing out") pendingVerdictCell(g, "NO NOTE", "AWAITING VERDICT", null) - pendingVerdictCell(g, "VALIDATING", "VALIDATING", "checking the top candidate") /* --- actions -------------------------------------------------------------- */ @@ -533,8 +417,6 @@ export function buildNodeCatalogue(): NodeCatalogue { edgeCell(g, "CAUSAL · BARE", { kind: "causal" }) edgeCell(g, "CAUSAL · LABELLED", { kind: "causal", label: "SEEDED" }) edgeCell(g, "CAUSAL · LIVE", { kind: "causal", label: "RUNNING", live: true }) - edgeCell(g, "FAN", { kind: "fan" }) - edgeCell(g, "FAN · LIVE", { kind: "fan", live: true }) edgeCell(g, "ROADMAP", { kind: "roadmap", label: "AUTOFIX" }) edgeCell(g, "ROADMAP · LIVE", { kind: "roadmap", label: "AUTOFIX", live: true }) diff --git a/apps/web/src/lib/collections/investigation-collections.ts b/apps/web/src/lib/collections/investigation-collections.ts index ce1fcef73..67879de2c 100644 --- a/apps/web/src/lib/collections/investigation-collections.ts +++ b/apps/web/src/lib/collections/investigation-collections.ts @@ -1,35 +1,29 @@ /** - * Per-investigation collections, and why they are not part of `OrgCollections`. + * The per-investigation collection, and why it is not part of `OrgCollections`. * * Every other synced collection is org-wide and there is exactly one live set at - * a time, because the app shows one org at a time. These two are scoped to a + * a time, because the app shows one org at a time. This one is scoped to a * single investigation — an org accumulates investigations forever and a browser * renders one, so an org-wide shape would stream the whole history to read one - * page. That makes their lifecycle navigation-shaped rather than session-shaped: - * a new set per investigation opened, torn down once nothing reads it. + * page. That makes its lifecycle navigation-shaped rather than session-shaped: + * a new one per investigation opened, torn down once nothing reads it. * * The registry is keyed on `(orgId, generation, investigationId)` so it composes * with the org set's two invalidations — an org switch and a self-heal generation * bump both mint fresh streams here too. */ import { cleanupCollectionWhenIdle, getCollectionsGeneration } from "./org-collections" -import { - createInvestigationCollection, - createInvestigationLensRunsCollection, - type InvestigationCollection, - type InvestigationLensRunsCollection, -} from "./investigations" +import { createInvestigationCollection, type InvestigationCollection } from "./investigations" export type InvestigationCollections = { readonly key: string readonly investigation: InvestigationCollection - readonly lensRuns: InvestigationLensRunsCollection } /** * At most one investigation is on screen, so a single-entry cache is the whole * requirement — and it is also what bounds the shape handles a session can open. - * Navigating between runs tears the previous pair down as it drains. + * Navigating between runs tears the previous one down as it drains. */ let current: InvestigationCollections | null = null @@ -46,15 +40,11 @@ export const getInvestigationCollections = ( current = { key, investigation: createInvestigationCollection(orgId, investigationId), - lensRuns: createInvestigationLensRunsCollection(orgId, investigationId), } // Swap first, tear down after — an in-flight read must never resolve against // the collection we are replacing. Deferred until its live queries unsubscribe, // for the reason `scheduleOrgCollectionsCleanup` defers: cleaning up underneath // a live query restarts sync on a dead collection. - if (previous) { - cleanupCollectionWhenIdle(previous.investigation) - cleanupCollectionWhenIdle(previous.lensRuns) - } + if (previous) cleanupCollectionWhenIdle(previous.investigation) return current } diff --git a/apps/web/src/lib/collections/investigations.test.ts b/apps/web/src/lib/collections/investigations.test.ts index 8d1fe9d35..9dbdd43a4 100644 --- a/apps/web/src/lib/collections/investigations.test.ts +++ b/apps/web/src/lib/collections/investigations.test.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from "vitest" -import { - deriveValidator, - liveLensRuns, - rowsToInvestigation, - type InvestigationLensRunRow, - type InvestigationRow, -} from "./investigations" +import { rowsToInvestigation, type InvestigationRow } from "./investigations" const row = (overrides: Partial = {}): InvestigationRow => ({ id: "88888888-8888-4888-8888-888888888888", @@ -57,32 +51,9 @@ const row = (overrides: Partial = {}): InvestigationRow => ({ ...overrides, }) -const lens = (overrides: Partial = {}): InvestigationLensRunRow => ({ - id: "lens-row-1", - org_id: "org_1", - investigation_id: "88888888-8888-4888-8888-888888888888", - lens_id: "deploy_correlation", - attempt: 1, - ordinal: 0, - status: "reported", - verdict: "promoted", - claim: "a deploy landed before the onset", - reason: null, - progress_note: null, - confidence: "high", - tool_count: 3, - elapsed_ms: 9400, - lens_name: null, - lens_question: null, - priority: null, - deadline_hit: false, - started_at: "2026-08-01T14:02:04.000Z", - ...overrides, -}) - describe("rowsToInvestigation", () => { it("rebuilds the object the page renders", () => { - const investigation = rowsToInvestigation(row(), [lens()]) + const investigation = rowsToInvestigation(row()) expect(investigation).not.toBeNull() expect(investigation).toMatchObject({ object: "investigation", @@ -124,7 +95,6 @@ describe("rowsToInvestigation", () => { unchecked: ["Pool depth: payments-api emits no connection metrics"], }, }), - [lens()], ) expect(investigation?.status).toBe("inconclusive") expect(investigation?.report?.ruledOut).toEqual([ @@ -141,7 +111,7 @@ describe("rowsToInvestigation", () => { * cast hid; the server has had `fallbackSnapshot` for this all along. */ it("substitutes a snapshot for an investigation opened without one", () => { - const investigation = rowsToInvestigation(row({ snapshot_json: null }), []) + const investigation = rowsToInvestigation(row({ snapshot_json: null })) expect(investigation?.snapshot).toMatchObject({ title: "Error incident", facts: [{ label: "Incident", value: "018f2b3c-4d5e-6f70-8192-a3b4c5d6e7f8" }], @@ -159,7 +129,6 @@ describe("rowsToInvestigation", () => { }, snapshot_json: null, }), - [], ) expect(investigation?.subject).toMatchObject({ type: "freeform", title: "why is checkout slow" }) expect(investigation?.snapshot.title).toBe("why is checkout slow") @@ -172,61 +141,8 @@ describe("rowsToInvestigation", () => { */ it("returns null rather than a half-built object when the subject will not decode", () => { expect( - rowsToInvestigation(row({ subject_json: { type: "incident", incidentKind: "error" } }), []), + rowsToInvestigation(row({ subject_json: { type: "incident", incidentKind: "error" } })), ).toBeNull() - expect(rowsToInvestigation(row({ status: "not-a-status" }), [])).toBeNull() - }) -}) - -describe("liveLensRuns", () => { - /** - * A retried run leaves the previous attempt's lanes in the table. Rendering - * them beside the attempt that superseded them shows one run assembled from - * two — the same filter the service applies. - */ - it("keeps only the current attempt, in dispatch order", () => { - const lanes = liveLensRuns(row({ fanout_attempt: 2 }), [ - lens({ id: "b", attempt: 2, ordinal: 1, lens_id: "second" }), - lens({ id: "stale", attempt: 1, ordinal: 0, lens_id: "previous" }), - lens({ id: "a", attempt: 2, ordinal: 0, lens_id: "first" }), - ]) - expect(lanes.map((lane) => lane.lens_id)).toEqual(["first", "second"]) - }) - - it("ignores lanes belonging to another investigation", () => { - expect(liveLensRuns(row(), [lens({ investigation_id: "other" })])).toHaveLength(0) - }) -}) - -describe("deriveValidator", () => { - it("is absent before the fan-out has anything to validate", () => { - expect(deriveValidator(row({ fanout_state: "none" }), [])).toBeNull() - expect(deriveValidator(row({ fanout_state: "queued" }), [])).toBeNull() - }) - - it("blocks while the lanes are still reporting", () => { - expect( - deriveValidator(row({ fanout_state: "running" }), [lens({ status: "checking" })]), - ).toMatchObject({ status: "blocked" }) - }) - - /** Mirrors `validatorFor`: an unwritten note falls back to the lane tally. */ - it("tallies the lanes when the run ranked without writing a note", () => { - expect( - deriveValidator(row({ fanout_state: "ranked", validator_note: null }), [ - lens({ verdict: "promoted" }), - lens({ verdict: "ruled_out" }), - ]), - ).toMatchObject({ - status: "ranked", - note: "1 promoted · 0 merged · 1 ruled out", - elapsedSeconds: 8.2, - }) - }) - - it("prefers the stored note when there is one", () => { - expect( - deriveValidator(row({ fanout_state: "rejected_all", validator_note: "nothing survived" }), []), - ).toMatchObject({ status: "rejected_all", note: "nothing survived" }) + expect(rowsToInvestigation(row({ status: "not-a-status" }))).toBeNull() }) }) diff --git a/apps/web/src/lib/collections/investigations.ts b/apps/web/src/lib/collections/investigations.ts index ee0e3ab3b..f8e3d66e7 100644 --- a/apps/web/src/lib/collections/investigations.ts +++ b/apps/web/src/lib/collections/investigations.ts @@ -1,9 +1,9 @@ /** - * The investigation detail page's two synced shapes, and the mapper that turns - * their rows back into the `V2Investigation` the page already renders. + * The investigation detail page's synced shape, and the mapper that turns its + * row back into the `V2Investigation` the page already renders. * * This replaced a 3s poll of `/v2/investigations/:id`. The poll capped how live - * the provenance canvas could be — a lane transition was up to three seconds + * the provenance canvas could be — a verdict landing was up to three seconds * stale — and it could not carry a clock at all, so every elapsed readout had to * be re-derived on the client anyway. * @@ -84,34 +84,8 @@ export const InvestigationRowSchema = Schema.Struct({ }) export type InvestigationRow = typeof InvestigationRowSchema.Type -export const InvestigationLensRunRowSchema = Schema.Struct({ - id: Schema.String, - org_id: Schema.String, - investigation_id: Schema.String, - lens_id: Schema.String, - attempt: Schema.Number, - ordinal: Schema.Number, - status: Schema.String, - verdict: Schema.String, - claim: Schema.NullOr(Schema.String), - reason: Schema.NullOr(Schema.String), - progress_note: Schema.NullOr(Schema.String), - confidence: Schema.NullOr(Schema.String), - tool_count: Schema.Number, - elapsed_ms: Schema.NullOr(Schema.Number), - lens_name: Schema.NullOr(Schema.String), - lens_question: Schema.NullOr(Schema.String), - priority: Schema.NullOr(Schema.Number), - deadline_hit: Schema.Boolean, - started_at: Schema.NullOr(Schema.String), -}) -export type InvestigationLensRunRow = typeof InvestigationLensRunRowSchema.Type - // Mappers (mirror toV2Investigation + InvestigationService's row mappers) -/** Mirrors `elapsedSeconds` in InvestigationService — ms → one decimal place. */ -const elapsedSeconds = (ms: number | null): number | null => (ms == null ? null : Math.round(ms / 100) / 10) - /** * The stored subject, renamed to the wire's snake_case shape. * @@ -208,69 +182,14 @@ const fallbackSnapshot = (subject: V2Investigation["subject"]): V2Investigation[ }) /** - * The validator lane, derived rather than stored — mirrors `validatorFor` in - * InvestigationService. Its status is a function of the fan-out state, and its - * note falls back to a tally of the lanes, so a run that ranked without writing a - * note still says what it concluded. - */ -export const deriveValidator = ( - row: InvestigationRow, - lenses: ReadonlyArray, -): V2Investigation["validator"] => { - if (row.fanout_state === "none" || row.fanout_state === "queued") return null - const elapsed = elapsedSeconds(row.validator_elapsed_ms) - const count = (verdict: string) => lenses.filter((lens) => lens.verdict === verdict).length - - if (row.fanout_state === "ranked" || row.fanout_state === "superseded") { - return { - status: "ranked", - note: - row.validator_note ?? - `${count("promoted")} promoted · ${count("merged")} merged · ${count("ruled_out")} ruled out`, - elapsedSeconds: elapsed, - } - } - if (row.fanout_state === "rejected_all") { - return { - status: "rejected_all", - note: row.validator_note ?? "no candidate held up", - elapsedSeconds: elapsed, - } - } - // `running` / `validating` — blocked is what "the validator has nothing to rank - // yet" is called, and the boards read it to withhold a held count. - return { status: "blocked", note: row.validator_note ?? "waiting on the lenses", elapsedSeconds: elapsed } -} - -/** - * Lanes belonging to the live attempt, in dispatch order. - * - * Both halves matter and both mirror the server. A retried run leaves the - * previous attempt's lanes in the table, and rendering them beside the attempt - * that superseded them shows one run assembled from two; and ordering is a - * contract the boards rely on — the fan reads top-to-bottom as it ran. - */ -export const liveLensRuns = ( - row: InvestigationRow, - lenses: ReadonlyArray, -): ReadonlyArray => - lenses - .filter((lens) => lens.investigation_id === row.id && lens.attempt === row.fanout_attempt) - .toSorted((left, right) => left.ordinal - right.ordinal) - -/** - * The whole page's object, rebuilt from the two shapes. + * The whole page's object, rebuilt from the row. * * `report_json` and `snapshot_json` are passed through as the stored documents: * the server's only work on them is decoding evidence trace ids, which is a * validation step, not a transform — the values the browser renders are the * stored ones either way. */ -export const rowsToInvestigation = ( - row: InvestigationRow, - lenses: ReadonlyArray, -): V2Investigation | null => { - const lanes = liveLensRuns(row, lenses) +export const rowsToInvestigation = (row: InvestigationRow): V2Investigation | null => { const subject = rowToSubject(row) if (subject === null) return null const stored = row.snapshot_json == null ? Option.none() : decodeSnapshot(row.snapshot_json) @@ -293,22 +212,10 @@ export const rowsToInvestigation = ( started_at: row.started_at, diagnosed_at: row.diagnosed_at, updated_at: row.updated_at, - lens_runs: lanes.map((lens) => ({ - lensId: lens.lens_id, - status: lens.status, - verdict: lens.verdict, - claim: lens.claim, - reason: lens.reason, - progressNote: lens.progress_note, - confidence: lens.confidence, - toolCount: lens.tool_count, - elapsedSeconds: elapsedSeconds(lens.elapsed_ms), - name: lens.lens_name, - question: lens.lens_question, - priority: lens.priority, - deadlineHit: lens.deadline_hit, - })), - validator: deriveValidator(row, lanes), + // Fan-out bookkeeping is still on the wire for rows written before the + // single-agent rework; nothing renders it any more. + lens_runs: [], + validator: null, fanout: { state: row.fanout_state, size: row.fanout_size }, } return Option.getOrNull(decodeInvestigation(candidate)) @@ -326,15 +233,4 @@ export const createInvestigationCollection = (orgId: string, investigationId: st getKey: (row) => row.id, }) -export const createInvestigationLensRunsCollection = (orgId: string, investigationId: string) => - createSyncedCollection({ - shape: "investigation_lens_runs", - scope: investigationId, - orgId, - schema: InvestigationLensRunRowSchema, - parser: timestamptzParser, - getKey: (row) => row.id, - }) - export type InvestigationCollection = ReturnType -export type InvestigationLensRunsCollection = ReturnType diff --git a/apps/web/src/routes/investigations/index.tsx b/apps/web/src/routes/investigations/index.tsx index c7bad6e97..0e93683a8 100644 --- a/apps/web/src/routes/investigations/index.tsx +++ b/apps/web/src/routes/investigations/index.tsx @@ -407,10 +407,6 @@ function TriageStrip({ investigations }: { investigations: ReadonlyArray entry.status === "resolved" && toEpochMs(entry.updated_at) >= cutoff, ) - // Dispatched lenses, not the computed size: a single-pass run persists a - // size of 1 with zero lenses, so summing size reports lenses in flight for - // runs that never dispatched one. - const lensesInFlight = running.reduce((total, entry) => total + entry.lens_runs.length, 0) const critical = review.filter( (entry) => (entry.severity ?? entry.snapshot.severity) === "critical", ).length @@ -422,12 +418,8 @@ function TriageStrip({ investigations }: { investigations: ReadonlyArray ms !== null && Number.isFinite(ms) && ms >= 0) .sort((a, b) => a - b) const median = durations.length > 0 ? durations[Math.floor(durations.length / 2)]! : null - const avgLenses = - resolved.length > 0 - ? resolved.reduce((total, entry) => total + entry.lens_runs.length, 0) / resolved.length - : null - return { running, review, resolved, lensesInFlight, critical, median, avgLenses } + return { running, review, resolved, critical, median } }, [investigations]) return ( @@ -443,7 +435,7 @@ function TriageStrip({ investigations }: { investigations: ReadonlyArray @@ -537,9 +525,9 @@ function HubHero({ onSubmit, busy }: { onSubmit: (title: string) => void | Promi Ask, and Maple goes and finds out.

    - It dispatches up to five agents, each attacking the problem from a different angle — deploys, - dependencies, saturation, traffic — then a validator ranks what they found and promotes one - answer. + One agent reads the traces, logs and metrics around it, tests the likely explanations — + deploys, dependencies, saturation, traffic — and comes back with a cause, the evidence for it, + and what it ruled out.