diff --git a/bin/agenthub.js b/bin/agenthub.js index d28b2160..c3e995ff 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -2766,22 +2766,6 @@ async function cmdStage(args) { result: flag("result"), }); console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`); - - // A stage name matching no node (nor alias) still stores — setStage takes the - // string verbatim — but phaseIdx() then returns -1, so nothing renders as - // `current` and progress reads 0. Warn, never fail: a typo must not break a - // declaration the pipeline can still record, but it must not pass silently. - // Skipped for `--result fail`, which paints the node `failed` rather than - // `current` and would otherwise look identical to an unknown stage. - const nodes = lane.pipeline_nodes || []; - if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) { - console.error( - c.yellow( - `! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` + - `pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}` - ) - ); - } } // ── Command catalog ───────────────────────────────────────────────────────── diff --git a/client/src/components/workflows/LanePipelinePanel.tsx b/client/src/components/workflows/LanePipelinePanel.tsx index 35035384..24558df6 100644 --- a/client/src/components/workflows/LanePipelinePanel.tsx +++ b/client/src/components/workflows/LanePipelinePanel.tsx @@ -1,11 +1,11 @@ /** * @file LanePipelinePanel.tsx * @description How often each node of this dashboard's OWN pipelines actually - * gets passed, aggregated over every lane and every archived feature by + * gets passed, aggregated over every recorded run by * `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed - * with evidence / passed without / failed, plus the raw counts, so a stage that - * everyone claims but nobody proves is visible at a glance. A lane picker - * narrows the same aggregation to one lane and its archived features. + * with evidence / passed without / failed / reached but only inferred, plus the + * raw counts, so a stage that everyone claims but nobody proves is visible at a + * glance. A lane picker narrows the same aggregation to one lane. * Read-only — the live per-lane view and every lane action stay on the * Workspace page. * @author Nguyễn Ngọc Trí Vĩ @@ -101,7 +101,9 @@ export function LanePipelinePanel() { ); } - const maxEntered = Math.max(...selected.nodes.map((n) => n.entered), 1); + // Detected-only reaches share the scale: a node the hooks saw fifty times and + // nobody ever declared should read as busy, not as empty. + const maxEntered = Math.max(...selected.nodes.map((n) => n.entered + n.detected), 1); return (
@@ -127,6 +129,7 @@ export function LanePipelinePanel() { +
@@ -178,11 +181,23 @@ export function LanePipelinePanel() { style={{ width: `${pct(n.failed, maxEntered)}%` }} title={t("lanes.legendFailed")} /> +
{n.entered === 0 ? ( - t("lanes.never") + // Reached but never declared still beats "never reached" — it + // is the difference between "you skip this stage" and "you do + // it without saying so". + n.detected > 0 ? ( + t("lanes.detectedOnly", { count: n.detected }) + ) : ( + t("lanes.never") + ) ) : ( <> {n.passed}/{n.entered} diff --git a/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx b/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx index c4b2e298..c41df058 100644 --- a/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx +++ b/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx @@ -29,6 +29,7 @@ const node = (id: string, over: Record = {}) => ({ passed: 0, withEvidence: 0, failed: 0, + detected: 0, ...over, }); @@ -114,6 +115,24 @@ describe("LanePipelinePanel", () => { const ship = await screen.findByTestId("pipeline-stat-ship"); expect(ship).not.toHaveTextContent("%"); + expect(ship).toHaveTextContent(/never reached/i); + }); + + it("separates a node the hooks saw from one that never happened", async () => { + // `gate` was reached in two runs but never declared. Reporting that as + // "never reached" is what made the panel look broken to someone who works + // without running the pipeline commands. + statsMock.mockResolvedValue({ + ...STATS, + pipelines: STATS.pipelines.map((p, i) => + i === 0 ? { ...p, nodes: [...p.nodes, node("gate", { detected: 2 })] } : p + ), + }); + render(); + + const gate = await screen.findByTestId("pipeline-stat-gate"); + expect(gate).not.toHaveTextContent(/never reached/i); + expect(gate).toHaveTextContent("2"); }); it("switches templates through the picker", async () => { diff --git a/client/src/i18n/locales/en/workflows.json b/client/src/i18n/locales/en/workflows.json index 1ed982ca..afee9410 100644 --- a/client/src/i18n/locales/en/workflows.json +++ b/client/src/i18n/locales/en/workflows.json @@ -246,7 +246,9 @@ "never": "never reached", "copyCommand": "Copy: {{command}}", "empty": "No lane has recorded a stage yet. Create one with", - "emptyLane": "This lane has not recorded a stage yet." + "emptyLane": "This lane has not recorded a stage yet.", + "legendDetected": "reached, not declared", + "detectedOnly": "detected ×{{count}}" }, "errorPropagation": { "title": "Error Propagation Map", diff --git a/client/src/i18n/locales/vi/workflows.json b/client/src/i18n/locales/vi/workflows.json index fab9b43a..ec53e100 100644 --- a/client/src/i18n/locales/vi/workflows.json +++ b/client/src/i18n/locales/vi/workflows.json @@ -246,7 +246,9 @@ "never": "chưa từng tới", "copyCommand": "Sao chép: {{command}}", "empty": "Chưa lane nào ghi nhận giai đoạn. Tạo lane bằng", - "emptyLane": "Lane này chưa ghi nhận giai đoạn nào." + "emptyLane": "Lane này chưa ghi nhận giai đoạn nào.", + "legendDetected": "đã chạm, chưa khai báo", + "detectedOnly": "suy đoán ×{{count}}" }, "errorPropagation": { "title": "Bản đồ lan truyền lỗi", diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 5d54f938..bc7cb323 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2507,9 +2507,9 @@ export interface LaneCounts { } /** - * How often one pipeline node was actually passed, summed over every lane and - * every archived feature. `passed + failed === entered`; `withEvidence` is the - * subset of passes that left an artifact, so a claim never reads as a proof. + * How often one pipeline node was actually passed, summed over every recorded + * run. `passed + failed === entered`; `withEvidence` is the subset of passes + * that left an artifact, so a claim never reads as a proof. */ export interface PipelineNodeStat { id: string; @@ -2523,6 +2523,10 @@ export interface PipelineNodeStat { passed: number; withEvidence: number; failed: number; + /** Runs that reached this node by inference from the hook stream only, with + * no declaration. Deliberately outside `entered`: it says the work happened, + * not that the gate was met. */ + detected: number; } /** Per-node pass counts, grouped by pipeline template (busiest first). */ @@ -2530,7 +2534,8 @@ export interface PipelineStats { pipelines: { id: string; name: string; - /** Lanes + archived features recorded against this template. */ + /** Passes through this template that recorded at least one stage. A lane + * that ships three features contributes three. */ runs: number; nodes: PipelineNodeStat[]; }[]; diff --git a/docs/API.md b/docs/API.md index 1004e14a..a6c78bac 100644 --- a/docs/API.md +++ b/docs/API.md @@ -611,14 +611,23 @@ Converge the clone-root `proof/` directory (if it exists at `/proof`) GET /api/lanes/pipeline-stats ``` -How often each pipeline node was actually passed, aggregated over every lane -**and** every archived feature (an archived feature is a finished run of a -pipeline, so it carries most of the history once a lane starts its next -feature). Grouped by pipeline template, busiest first. +How often each pipeline node was actually passed, aggregated over every recorded +run and grouped by pipeline template, busiest first. + +Read from `lane_stage_events`, the append-only log every stage declaration +writes — **not** from `lanes.stages`. That blob describes the pass in flight: it +is overwritten when a node is re-entered and truncated when the lane moves +backwards, so reading it counted one run per lane row no matter how many +features that lane shipped, and no amount of use ever moved the numbers. A run +is one pass through the template: the log starts a new one when the lane moves +backwards or leaves the template's last node. + +Templates configured on a lane are always listed, at zero, even before that lane +records anything — a pipeline going unused has to be visible as such. **Query parameters:** -- **`lane`** — lane id; narrows the aggregation to that lane and its own - archived features. Omit (or pass `all`) for every lane. +- **`lane`** — lane id; narrows the aggregation to that lane's own runs. Omit + (or pass `all`) for every lane. ```json { @@ -629,7 +638,7 @@ feature). Grouped by pipeline template, busiest first. "runs": 4, "nodes": [ { "id": "plan", "label": "plan", "icon": "🧭", "gate": false, - "entered": 4, "passed": 4, "withEvidence": 4, "failed": 0 } + "entered": 4, "passed": 4, "withEvidence": 4, "failed": 0, "detected": 0 } ] } ], @@ -637,6 +646,11 @@ feature). Grouped by pipeline template, busiest first. } ``` +- **`detected`** — runs that reached the node by inference from the hook stream + with no declaration. Counted apart from `entered`: it says the work happened, + not that the gate was met. A node the hooks saw is reported as such rather + than as "never reached", which is what a session that never runs the pipeline + commands used to look like. - **`lanes`** — every lane, for the picker. Never narrowed by `lane`, so the response always offers the way back to the other lanes. - **`runs`** — lanes plus archived features recorded against this template. diff --git a/docs/LANES.md b/docs/LANES.md index 5195efae..bbcd9d14 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -687,8 +687,18 @@ things beyond the ordinary transition: The node the declaration lands on keeps its own record (its evidence and `result` survive), and `stage_since` restarts, so time-on-phase measures this pass. Records stored under a key matching **no** node in the pipeline are left -untouched — their position is unknown, so they cannot be proven to be ahead; -for the same reason, declaring a stage that matches no node prunes nothing. +untouched — their position is unknown, so they cannot be proven to be ahead. +Such keys only exist on lanes that predate validation: `POST +/api/lanes/:id/stage` now rejects a stage naming no node of the lane's pipeline +with **400 `EBADSTAGE`** and lists the ones that exist. Aliases still resolve, +so this catches typos and names borrowed from another template — `agenthub +stage test` on a `ship-feature` lane, whose CI node is `gates` — which used to +be stored, warned about on stderr, and then counted nowhere. + +Every declaration is also appended to `lane_stage_events`, the append-only log +the pass-rate report reads. `lanes.stages` describes the pass in flight and is +overwritten and truncated by the rules above; the log is what remembers that +the previous pass happened at all. Going back this way is a *declaration*, not a reset: the lane, its worktree, and its archived features are untouched. To start a genuinely separate piece of work diff --git a/server/__tests__/lane-stage-events.test.js b/server/__tests__/lane-stage-events.test.js new file mode 100644 index 00000000..049aa5c8 --- /dev/null +++ b/server/__tests__/lane-stage-events.test.js @@ -0,0 +1,137 @@ +/** + * @file lane-stage-events.test.js + * @description Tests for the append-only stage log behind Lane Pipeline Pass + * Rates. + * + * The panel used to read `lanes.stages`, which is a snapshot of the current + * pass: re-entering a node overwrote its record and moving backwards deleted + * every record ahead. Ten features shipped through one lane therefore counted + * as one run, and no amount of use ever moved the numbers. These tests pin the + * two properties that fixes it — a pass is never overwritten, and passes are + * separated into runs — plus the detected/declared split the panel draws. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, beforeEach, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lane-stage-events-")); +process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); + +const { db } = require("../db"); +const lanesLib = require("../lib/lanes"); + +let laneId; + +after(() => { + try { + fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 }); + } catch { + /* best-effort temp cleanup */ + } +}); + +before(() => { + db.prepare( + "INSERT INTO lanes (title, cwd, pipeline, tool) VALUES ('t', ?, 'default', 'claude')" + ).run(path.join(TMP, "work")); + laneId = db.prepare("SELECT id FROM lanes ORDER BY id DESC LIMIT 1").get().id; +}); + +beforeEach(() => { + db.prepare("DELETE FROM lane_stage_events WHERE lane_id = ?").run(laneId); + db.prepare("UPDATE lanes SET stage = 'idle', stages = '{}' WHERE id = ?").run(laneId); +}); + +const events = () => + db + .prepare("SELECT node_id, run_seq, source, result, evidence FROM lane_stage_events ORDER BY id") + .all(); + +describe("lane_stage_events", () => { + it("records one event per declaration, including a node entered twice", () => { + lanesLib.setStage(laneId, { stage: "plan" }); + lanesLib.setStage(laneId, { stage: "implement" }); + lanesLib.setStage(laneId, { stage: "implement", evidence: "second pass" }); + + const rows = events(); + assert.equal(rows.length, 3); + assert.deepEqual( + rows.map((r) => r.node_id), + ["plan", "implement", "implement"] + ); + // The snapshot keeps one record for `implement`; the log keeps both. + assert.equal(Object.keys(lanesLib.getLane(laneId).stages).length, 2); + }); + + it("keeps the records of a finished pass when the lane moves backwards", () => { + for (const stage of ["plan", "implement", "review"]) lanesLib.setStage(laneId, { stage }); + lanesLib.setStage(laneId, { stage: "plan" }); + + // The snapshot truncates ahead of `plan` — that is deliberate, it describes + // the pass in flight. The log must not lose what already happened. + const snapshot = Object.keys(lanesLib.getLane(laneId).stages); + assert.deepEqual(snapshot.sort(), ["plan"]); + assert.deepEqual( + events().map((r) => r.node_id), + ["plan", "implement", "review", "plan"] + ); + }); + + it("starts a new run when the lane moves backwards", () => { + for (const stage of ["plan", "implement", "review"]) lanesLib.setStage(laneId, { stage }); + lanesLib.setStage(laneId, { stage: "plan" }); + lanesLib.setStage(laneId, { stage: "implement" }); + + assert.deepEqual( + events().map((r) => `${r.node_id}#${r.run_seq}`), + ["plan#1", "implement#1", "review#1", "plan#2", "implement#2"] + ); + }); + + it("starts a new run when the pipeline's last node is left behind", () => { + lanesLib.setStage(laneId, { stage: "done" }); + lanesLib.setStage(laneId, { stage: "intake" }); + + assert.deepEqual( + events().map((r) => `${r.node_id}#${r.run_seq}`), + ["done#1", "intake#2"] + ); + }); + + it("carries the result and whether evidence was left", () => { + lanesLib.setStage(laneId, { stage: "review", result: "fail" }); + lanesLib.setStage(laneId, { stage: "gate", evidence: "GO — 2 reviewers" }); + + const [review, gate] = events(); + assert.equal(review.result, "fail"); + assert.equal(review.evidence, 0); + assert.equal(gate.result, null); + assert.equal(gate.evidence, 1); + }); + + it("records a detection as its own source, never as a declaration", () => { + const wrote = lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" }); + assert.equal(wrote.written, true); + + const rows = events(); + assert.equal(rows.length, 1); + assert.equal(rows[0].source, "detected"); + assert.equal(rows[0].node_id, "implement"); + // A detection must not move the declared stage — same rule as before. + assert.equal(lanesLib.getLane(laneId).stage, "idle"); + }); + + it("records nothing when a detection is rejected", () => { + lanesLib.setStage(laneId, { stage: "review" }); + db.prepare("DELETE FROM lane_stage_events WHERE lane_id = ?").run(laneId); + + const wrote = lanesLib.recordDetection(laneId, { nodeId: "plan", signal: "`read`" }); + assert.equal(wrote.written, false); + assert.deepEqual(events(), []); + }); +}); diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index 79b44bc4..7ca8141e 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -88,6 +88,21 @@ describe("/api/lanes", () => { assert.equal(r.status, 409); }); + it("rejects a stage that names no node of the lane's pipeline", async () => { + // Silently storing it is what made "CI gates" read never-reached on a lane + // where the agent had been declaring `test` (the default pipeline's name) + // all along. + const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "implemnt" }); + assert.equal(r.status, 400); + assert.equal(r.body.error.code, "EBADSTAGE"); + assert.ok(r.body.error.stages.includes("implement")); + }); + + it("accepts a stage declared by one of the node's aliases", async () => { + const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "coding" }); + assert.equal(r.status, 200); + }); + it("reports a stage and returns node states", async () => { const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "review", diff --git a/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index a1e39b90..491338a6 100644 --- a/server/__tests__/lanes-cli.test.js +++ b/server/__tests__/lanes-cli.test.js @@ -203,29 +203,29 @@ describe("agenthub stage", () => { assert.match(`${r.stdout}${r.stderr}`, /no lane/i); }); - it("warns, but still records, a stage name matching no pipeline node", async () => { - // setStage stores the string verbatim, so a typo'd stage is accepted and - // then renders nowhere (phaseIdx -1, progress 0). Silent acceptance is how - // a lane ends up looking unstarted for an entire pipeline run. + it("refuses a stage name matching no pipeline node", async () => { + // It used to be recorded with a warning, which renders nowhere (phaseIdx + // -1, progress 0) and counts nowhere. A warning on stderr proved easy to + // miss — a real lane accumulated five such names — so the server rejects + // it and the agent is told which names exist. const r = await cli(["stage", "revieww"], LANE_DIR); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /revieww/); - assert.match(r.stderr, /matches no node/i); - assert.match(r.stderr, /revieww/); + assert.notEqual(r.status, 0); + const out = `${r.stdout}${r.stderr}`; + assert.match(out, /revieww/); + assert.match(out, /not a stage/i); + assert.match(out, /review/); }); - it("does not warn for a stage declared by alias", async () => { + it("accepts a stage declared by alias", async () => { const r = await cli(["stage", "planning"], LANE_DIR); assert.equal(r.status, 0, r.stderr); - assert.doesNotMatch(r.stderr, /matches no node/i); }); - it("does not warn for a valid stage reported as failed", async () => { - // --result fail paints the node `failed`, not `current`; the warning must - // not read that as an unknown stage. + it("accepts a valid stage reported as failed", async () => { + // --result fail paints the node `failed`, not `current`; the validation + // must not read that as an unknown stage. const r = await cli(["stage", "review", "--result", "fail"], LANE_DIR); assert.equal(r.status, 0, r.stderr); - assert.doesNotMatch(r.stderr, /matches no node/i); }); }); diff --git a/server/__tests__/workflows-usage-api.test.js b/server/__tests__/workflows-usage-api.test.js index 2e648566..b97fad2f 100644 --- a/server/__tests__/workflows-usage-api.test.js +++ b/server/__tests__/workflows-usage-api.test.js @@ -22,6 +22,7 @@ const assert = require("node:assert/strict"); const { createApp } = require("../index"); const lanesLib = require("../lib/lanes"); +const laneFeatures = require("../lib/lane-features"); const { db, stmts } = require("../db"); let server; @@ -61,8 +62,14 @@ function makeLane() { return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" }); } -function setStages(laneId, stages) { - db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId); +// Declare stages the way an agent does. Poking `lanes.stages` directly would +// test the snapshot the pass-rate report no longer reads: that blob describes +// the pass in flight, while the report counts the append-only stage log every +// declaration writes. +function declare(laneId, stages) { + for (const [stage, rec] of Object.entries(stages)) { + lanesLib.setStage(laneId, { stage, evidence: rec.evidence, result: rec.result }); + } } function node(pipelineStats, pipelineId, nodeId) { @@ -73,10 +80,10 @@ function node(pipelineStats, pipelineId, nodeId) { describe("GET /api/lanes/pipeline-stats", () => { it("counts a pass with evidence apart from a pass without one, and a fail as neither", async () => { const lane = makeLane(); - setStages(lane.id, { - plan: { enteredAt: "2026-01-01T00:00:00Z", evidence: "plan.md", result: null }, - implement: { enteredAt: "2026-01-01T01:00:00Z", evidence: null, result: null }, - tests: { enteredAt: "2026-01-01T02:00:00Z", evidence: null, result: "fail" }, + declare(lane.id, { + plan: { evidence: "plan.md", result: null }, + implement: { evidence: null, result: null }, + tests: { evidence: null, result: "fail" }, }); const res = await get("/api/lanes/pipeline-stats"); @@ -106,33 +113,35 @@ describe("GET /api/lanes/pipeline-stats", () => { assert.equal(node(res.body, "default", "ship").entered, 0); }); - it("keeps counting an archived feature's stages after the lane moves on", async () => { + it("keeps a finished feature's run after the lane starts the next one", async () => { + // The regression this guards: the report used to read the lane's live + // `stages` blob, which the next feature overwrites. Ship two features + // through one lane and the first one's gate had never happened. const lane = makeLane(); - db.prepare( - `INSERT INTO lane_features (lane_id, slug, pipeline, stage, stages, archived_at) - VALUES (?, 'old', 'default', 'done', ?, '2026-01-02T00:00:00Z')` - ).run(lane.id, JSON.stringify({ gate: { enteredAt: "x", evidence: "GO", result: null } })); + declare(lane.id, { gate: { evidence: "GO" } }); + laneFeatures.activateFeature(lane.id, "next-feature"); + declare(lane.id, { plan: { evidence: null } }); - const res = await get("/api/lanes/pipeline-stats"); + const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`); const gate = node(res.body, "default", "gate"); assert.equal(gate.passed, 1); assert.equal(gate.withEvidence, 1); + assert.equal(node(res.body, "default", "plan").passed, 1); + // Two passes through the pipeline, counted as two runs rather than one lane. + assert.equal(res.body.pipelines.find((p) => p.id === "default").runs, 2); }); - it("narrows to one lane and its own archived features with ?lane=", async () => { + it("narrows to one lane and all of its own runs with ?lane=", async () => { const mine = makeLane(); const other = makeLane(); - setStages(mine.id, { plan: { enteredAt: "x", evidence: "plan.md", result: null } }); - setStages(other.id, { plan: { enteredAt: "x", evidence: null, result: null } }); - db.prepare( - `INSERT INTO lane_features (lane_id, slug, pipeline, stage, stages, archived_at) - VALUES (?, 'mine-old', 'default', 'done', ?, '2026-01-02T00:00:00Z')` - ).run(mine.id, JSON.stringify({ plan: { enteredAt: "x", evidence: "old.md", result: null } })); + declare(mine.id, { plan: { evidence: "plan.md" }, done: {} }); + declare(mine.id, { plan: { evidence: "again.md" } }); + declare(other.id, { plan: { evidence: null } }); const res = await get(`/api/lanes/pipeline-stats?lane=${mine.id}`); assert.equal(res.status, 200); const plan = node(res.body, "default", "plan"); - // The lane itself plus its one archived feature — never the other lane's. + // Both of this lane's passes — never the other lane's. assert.deepEqual( { entered: plan.entered, withEvidence: plan.withEvidence }, { entered: 2, withEvidence: 2 } diff --git a/server/db.js b/server/db.js index 30d3bfd4..c0fd277e 100644 --- a/server/db.js +++ b/server/db.js @@ -457,6 +457,38 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_lane_features_lane ON lane_features(lane_id); + -- Append-only log of every stage a lane has ever entered. + -- + -- lanes.stages cannot answer "how often is this stage passed": it is a + -- snapshot of the CURRENT pass, overwritten when a node is re-entered and + -- truncated when the lane moves backwards. Pass rates read from it counted + -- one run per lane row no matter how many features that lane shipped. + -- + -- run_seq groups events into passes: it increments when the lane moves + -- backwards or leaves the pipeline's last node, which is what starting the + -- next feature looks like from here. + -- + -- source separates 'declared' (an agent ran the stage command) from + -- 'detected' (inferred from the hook stream). Both are recorded; only the + -- reader decides what a claim is worth. + CREATE TABLE IF NOT EXISTS lane_stage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lane_id INTEGER NOT NULL, + pipeline TEXT NOT NULL, + run_seq INTEGER NOT NULL DEFAULT 1, + node_id TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'declared', + result TEXT, + evidence INTEGER NOT NULL DEFAULT 0, + at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + FOREIGN KEY (lane_id) REFERENCES lanes(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_lane_stage_events_lane + ON lane_stage_events(lane_id, run_seq); + CREATE INDEX IF NOT EXISTS idx_lane_stage_events_pipeline + ON lane_stage_events(pipeline, node_id); + CREATE INDEX IF NOT EXISTS idx_lanes_session ON lanes(session_id); `); @@ -653,6 +685,52 @@ try { } db.prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_cwd_tool ON lanes(cwd, tool)").run(); +// Seed lane_stage_events from the snapshots that predate it, so upgrading does +// not read as "every pipeline has never run". Each lane's live `stages` blob +// becomes one run; each archived feature becomes one more. That is all the +// history those blobs ever held — the events table is what starts keeping more. +// +// Guarded on the table being empty rather than a migration flag: re-seeding a +// table someone truncated re-adds at most one run per lane, and a fresh install +// has nothing to copy. +if (db.prepare("SELECT count(*) AS c FROM lane_stage_events").get().c === 0) { + const insert = db.prepare( + `INSERT INTO lane_stage_events (lane_id, pipeline, run_seq, node_id, source, result, evidence, at) + VALUES (?, ?, ?, ?, 'declared', ?, ?, ?)` + ); + const seed = db.transaction(() => { + const rows = [ + ...db.prepare("SELECT id AS lane_id, pipeline, stages FROM lanes").all(), + ...db.prepare("SELECT lane_id, pipeline, stages FROM lane_features").all(), + ]; + const runByLane = new Map(); + for (const row of rows) { + let stages; + try { + stages = JSON.parse(row.stages || "{}"); + } catch { + continue; // a corrupt blob costs one row, never the migration + } + const entries = Object.entries(stages); + if (!entries.length) continue; + const runSeq = (runByLane.get(row.lane_id) || 0) + 1; + runByLane.set(row.lane_id, runSeq); + for (const [nodeId, rec] of entries) { + insert.run( + row.lane_id, + row.pipeline || "default", + runSeq, + nodeId, + rec && rec.result ? rec.result : null, + rec && rec.evidence ? 1 : 0, + (rec && rec.enteredAt) || new Date().toISOString() + ); + } + } + }); + seed(); +} + // Migrate: link agent rows to a workflow run. Workflow inner-agents are already // ingested as subagents (same subagents/ dir); these columns add the grouping + // phase that the run journal provides. Additive, safe on existing DBs. diff --git a/server/lib/lanes.js b/server/lib/lanes.js index b9ab320c..c4d4b360 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -305,6 +305,51 @@ function reservedPorts(excludeLaneId = null) { * has to keep saying so, or an inferred stage becomes indistinguishable from * one the agent actually claimed. */ +/** + * Append one row to the stage log — the history `lanes.stages` cannot keep, + * because that blob is the current pass and gets overwritten and truncated. + * + * The run a row belongs to is decided here rather than stored on the lane, so + * there is one rule and no column to fall out of sync. A new run starts when + * the lane moves BACKWARDS (the next feature begins at plan) or when the last + * event sat on the pipeline's final node (the previous feature finished). Any + * other move continues the run in flight. + * + * Never throws: a lane's stage must still advance if this log cannot be + * written — the dashboard losing a statistic beats an agent's pipeline dying. + */ +function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) { + try { + const pipeline = getPipeline(lane.pipeline); + const last = db + .prepare("SELECT node_id, run_seq FROM lane_stage_events WHERE lane_id = ? ORDER BY id DESC") + .get(lane.id); + let runSeq = 1; + if (last) { + const lastIdx = phaseIdx(pipeline, last.node_id); + const nextIdx = phaseIdx(pipeline, nodeId); + const wentBack = lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx; + const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx; + runSeq = last.run_seq + (wentBack || finished ? 1 : 0); + } + db.prepare( + `INSERT INTO lane_stage_events (lane_id, pipeline, run_seq, node_id, source, result, evidence, at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + lane.id, + lane.pipeline, + runSeq, + nodeId, + source, + result || null, + evidence ? 1 : 0, + nowIso() + ); + } catch { + /* the log is a report, never a gate on the lane's own state */ + } +} + function setStage(id, { stage, status, evidence, note, result, keepDetection } = {}) { const lane = getLane(id); if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" }); @@ -342,6 +387,11 @@ function setStage(id, { stage, status, evidence, note, result, keepDetection } = nowIso(), id ); + appendStageEvent(lane, next, { + source: "declared", + result: stages[next].result, + evidence: stages[next].evidence, + }); return getLane(id); } @@ -396,6 +446,7 @@ function recordDetection(id, { nodeId, signal } = {}) { db.prepare( "UPDATE lanes SET detected_stage = ?, detected_signal = ?, detected_at = ? WHERE id = ?" ).run(nodeId, signal || null, nowIso(), id); + appendStageEvent(lane, nodeId, { source: "detected" }); return { written: true }; } diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 89fef418..8de6c9f0 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -19,7 +19,7 @@ const { getPipeline, nodeStates, progressPct, - stageRecords, + phaseIdx, } = require("../lib/pipelines"); const laneFeatures = require("../lib/lane-features"); const proofLib = require("../lib/proof"); @@ -161,63 +161,100 @@ router.get("/pipeline-stats", (req, res) => { } } - const rows = + const events = laneId === null - ? [ - ...db.prepare("SELECT pipeline, stages FROM lanes").all(), - ...db.prepare("SELECT pipeline, stages FROM lane_features").all(), - ] - : [ - ...db.prepare("SELECT pipeline, stages FROM lanes WHERE id = ?").all(laneId), - ...db.prepare("SELECT pipeline, stages FROM lane_features WHERE lane_id = ?").all(laneId), - ]; + ? db + .prepare( + "SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events ORDER BY id" + ) + .all() + : db + .prepare( + "SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id" + ) + .all(laneId); const acc = new Map(); - for (const row of rows) { - let stages; - try { - stages = JSON.parse(row.stages); - } catch { - continue; // a corrupt blob costs one row, never the whole report - } - const pipeline = getPipeline(row.pipeline); + const entryFor = (pipelineId) => { + const pipeline = getPipeline(pipelineId); let entry = acc.get(pipeline.id); if (!entry) { - entry = { - id: pipeline.id, - name: pipeline.name || pipeline.id, - runs: 0, - nodes: pipeline.nodes.map((n) => ({ - id: n.id, - label: n.label, - icon: n.icon, - gate: n.gate, - command: n.command, - entered: 0, - passed: 0, - withEvidence: 0, - failed: 0, - })), - }; + entry = { id: pipeline.id, runs: new Set(), nodes: new Map(), template: pipeline }; acc.set(pipeline.id, entry); } - entry.runs += 1; - const records = stageRecords(pipeline, stages); - for (const [idx, rec] of records) { - const node = entry.nodes[idx]; - if (!node) continue; - node.entered += 1; - if (rec && rec.result === "fail") node.failed += 1; - else { - node.passed += 1; - if (rec && rec.evidence) node.withEvidence += 1; - } - } + return entry; + }; + + // Seed the templates the lanes in scope are configured with, before folding + // any events in. A lane that has recorded nothing still has to render its map + // — the picker is how you find out a pipeline is going unused, so a pipeline + // with zero runs must appear, at zero, rather than vanish. + const scopedLanes = + laneId === null + ? db.prepare("SELECT pipeline FROM lanes").all() + : db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId); + for (const row of scopedLanes) entryFor(row.pipeline); + + // Fold the event stream down to one verdict per (run, node): a run that + // entered a node twice is still one pass of it, and a declaration always + // beats a detection of the same node — the agent's own claim is the stronger + // statement, so a node it declared must never be reported as merely inferred. + for (const ev of events) { + const entry = entryFor(ev.pipeline); + const pipeline = entry.template; + entry.runs.add(`${ev.lane_id}#${ev.run_seq}`); + + const idx = phaseIdx(pipeline, ev.node_id); + if (idx === -1) continue; // a stage nothing in this pipeline is named after + const key = `${ev.lane_id}#${ev.run_seq}#${idx}`; + const prev = entry.nodes.get(key); + const declared = ev.source !== "detected"; + if (prev && prev.declared && !declared) continue; + entry.nodes.set(key, { + idx, + declared: declared || (prev ? prev.declared : false), + failed: declared ? ev.result === "fail" : prev ? prev.failed : false, + evidence: (declared && !!ev.evidence) || (prev ? prev.evidence : false), + }); } - // Busiest pipeline first: the one with the most recorded runs is the one the - // reader most likely came to look at. - const pipelines = [...acc.values()].sort((a, b) => b.runs - a.runs); + const pipelines = [...acc.values()] + .map((entry) => { + const nodes = entry.template.nodes.map((n) => ({ + id: n.id, + label: n.label, + icon: n.icon, + gate: n.gate, + command: n.command, + entered: 0, + passed: 0, + withEvidence: 0, + failed: 0, + detected: 0, + })); + for (const v of entry.nodes.values()) { + const node = nodes[v.idx]; + if (!node) continue; + if (!v.declared) { + // Reached, but only inferred from the hook stream. Counted apart from + // the pass rate: it says the work happened, not that a gate was met. + node.detected += 1; + continue; + } + node.entered += 1; + if (v.failed) node.failed += 1; + else { + node.passed += 1; + if (v.evidence) node.withEvidence += 1; + } + } + const name = entry.template.name || entry.id; + return { id: entry.id, name, runs: entry.runs.size, nodes }; + }) + // Busiest pipeline first: the one with the most recorded runs is the one + // the reader most likely came to look at. + .sort((a, b) => b.runs - a.runs); + const lanes = db.prepare("SELECT id, title, cwd FROM lanes ORDER BY id").all(); res.json({ pipelines, lanes }); }); @@ -407,9 +444,30 @@ router.patch("/:id", sameOriginGuard, (req, res) => { }); router.post("/:id/stage", sameOriginGuard, (req, res) => { - if (!lanesLib.getLane(req.params.id)) { + const current = lanesLib.getLane(req.params.id); + if (!current) { return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); } + // A stage naming no node of this lane's pipeline used to be stored and then + // silently ignored by every reader — `agenthub stage test` on a ship-feature + // lane (whose node is `gates`) left the CI row reading "never reached" with + // no hint anything was wrong. Aliases still resolve, so this rejects typos + // and wrong-pipeline names, not shorthand. + const requested = req.body && req.body.stage; + if (requested !== undefined && requested !== null && requested !== "") { + const pipeline = getPipeline(current.pipeline); + if (phaseIdx(pipeline, requested) === -1) { + return res.status(400).json({ + error: { + code: "EBADSTAGE", + message: + `"${requested}" is not a stage of the "${pipeline.id}" pipeline. ` + + `Nodes: ${pipeline.nodes.map((n) => n.id).join(", ")}`, + stages: pipeline.nodes.map((n) => n.id), + }, + }); + } + } const lane = lanesLib.setStage(req.params.id, req.body || {}); broadcastLane(lane.id); res.json({ lane: payload(lane) });