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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user