fix(lanes): count every pipeline run, not one per lane
Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.
Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. 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. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.
Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.
Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
@@ -127,6 +129,7 @@ export function LanePipelinePanel() {
|
||||
<LegendSwatch className="bg-status-success" label={t("lanes.legendEvidence")} />
|
||||
<LegendSwatch className="bg-status-warning" label={t("lanes.legendClaimed")} />
|
||||
<LegendSwatch className="bg-status-danger" label={t("lanes.legendFailed")} />
|
||||
<LegendSwatch className="bg-fg-muted/60" label={t("lanes.legendDetected")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,11 +181,23 @@ export function LanePipelinePanel() {
|
||||
style={{ width: `${pct(n.failed, maxEntered)}%` }}
|
||||
title={t("lanes.legendFailed")}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-fg-muted/60"
|
||||
style={{ width: `${pct(n.detected, maxEntered)}%` }}
|
||||
title={t("lanes.legendDetected")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="w-28 shrink-0 text-right tabular-nums text-fg-muted">
|
||||
{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")
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="text-fg-secondary">{n.passed}</span>/{n.entered}
|
||||
|
||||
@@ -29,6 +29,7 @@ const node = (id: string, over: Record<string, number | string> = {}) => ({
|
||||
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(<LanePipelinePanel />);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
}[];
|
||||
|
||||
+21
-7
@@ -611,14 +611,23 @@ Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/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.
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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(), []);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
+108
-50
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user