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:
2026-09-09 15:32:09 +07:00
parent 3549000fa9
commit 876c6de6d2
15 changed files with 520 additions and 121 deletions
@@ -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 () => {