feat(workflows): pick a lane in the pipeline pass-rate panel
The panel aggregated every lane at once, which answers "does this stage get proved across the project" but never "does THIS lane prove it". A lane picker now narrows the same aggregation through ?lane=<id> on GET /api/lanes/pipeline-stats, counting that lane plus its own archived features — dropping the features would hide most of a lane's history the moment it starts its next one. The response carries a `lanes` array that the filter never narrows: it populates the picker, so it has to keep offering every other lane even while the counts show one. For the same reason the picker renders above the empty branch rather than inside the populated one — narrowing to a lane that has recorded nothing would otherwise be a one-way trip, with no control left to select anything else. A lane filter that is not a positive integer is rejected with 400 EBADLANE rather than silently aggregating everything.
This commit is contained in:
@@ -118,6 +118,52 @@ describe("GET /api/lanes/pipeline-stats", () => {
|
||||
assert.equal(gate.passed, 1);
|
||||
assert.equal(gate.withEvidence, 1);
|
||||
});
|
||||
|
||||
it("narrows to one lane and its own archived features 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 } }));
|
||||
|
||||
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.
|
||||
assert.deepEqual(
|
||||
{ entered: plan.entered, withEvidence: plan.withEvidence },
|
||||
{ entered: 2, withEvidence: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
it("always returns every lane for the picker, even when narrowed", async () => {
|
||||
const lane = makeLane();
|
||||
const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`);
|
||||
assert.ok(res.body.lanes.length > 1);
|
||||
assert.ok(res.body.lanes.every((l) => l.id && typeof l.cwd === "string"));
|
||||
});
|
||||
|
||||
it("still describes the template for a lane that has recorded nothing", async () => {
|
||||
const lane = makeLane();
|
||||
const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(node(res.body, "default", "plan").entered, 0);
|
||||
});
|
||||
|
||||
it("returns no pipelines at all for a lane id that does not exist", async () => {
|
||||
const res = await get("/api/lanes/pipeline-stats?lane=999999");
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body.pipelines, []);
|
||||
});
|
||||
|
||||
it("rejects a lane filter that is not an id", async () => {
|
||||
const res = await get("/api/lanes/pipeline-stats?lane=../etc");
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error.code, "EBADLANE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/workflows/skill-usage", () => {
|
||||
|
||||
+26
-6
@@ -146,12 +146,31 @@ router.get("/pipelines", (_req, res) => res.json({ pipelines: listPipelines() })
|
||||
* separately rather than folded into the pass rate, because "claimed" and
|
||||
* "proved" are not the same fact — the same distinction the pipeline map draws
|
||||
* between `done` and `passed-no-evidence`. Also registered before "/:id".
|
||||
*
|
||||
* `?lane=<id>` narrows the same aggregation to one lane and its own archived
|
||||
* features. The returned `lanes` list is never narrowed — it populates the
|
||||
* picker, so it has to keep offering the way back to every other lane.
|
||||
*/
|
||||
router.get("/pipeline-stats", (_req, res) => {
|
||||
const rows = [
|
||||
...db.prepare("SELECT pipeline, stages FROM lanes").all(),
|
||||
...db.prepare("SELECT pipeline, stages FROM lane_features").all(),
|
||||
];
|
||||
router.get("/pipeline-stats", (req, res) => {
|
||||
const laneFilter = req.query.lane;
|
||||
let laneId = null;
|
||||
if (laneFilter !== undefined && laneFilter !== "" && laneFilter !== "all") {
|
||||
laneId = Number(laneFilter);
|
||||
if (!Number.isInteger(laneId) || laneId < 1) {
|
||||
return res.status(400).json({ error: { code: "EBADLANE", message: "lane must be an id" } });
|
||||
}
|
||||
}
|
||||
|
||||
const rows =
|
||||
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),
|
||||
];
|
||||
|
||||
const acc = new Map();
|
||||
for (const row of rows) {
|
||||
@@ -198,7 +217,8 @@ router.get("/pipeline-stats", (_req, res) => {
|
||||
// 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);
|
||||
res.json({ pipelines });
|
||||
const lanes = db.prepare("SELECT id, title, cwd FROM lanes ORDER BY id").all();
|
||||
res.json({ pipelines, lanes });
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user