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:
@@ -4,8 +4,10 @@
|
||||
* gets passed, aggregated over every lane and every archived feature 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. Read-only — the
|
||||
* live per-lane view and every lane action stay on the Workspace page.
|
||||
* everyone claims but nobody proves is visible at a glance. A lane picker
|
||||
* narrows the same aggregation to one lane and its archived features.
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -28,16 +30,17 @@ export function LanePipelinePanel() {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [data, setData] = useState<PipelineStats | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setData(await api.lanes.pipelineStats());
|
||||
setData(await api.lanes.pipelineStats(laneId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("failedLoad"));
|
||||
}
|
||||
}, [t]);
|
||||
}, [t, laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
@@ -62,11 +65,39 @@ export function LanePipelinePanel() {
|
||||
// Follow the list rather than pinning an id: templates can come and go.
|
||||
const selected = data.pipelines.find((p) => p.id === selectedId) ?? data.pipelines[0] ?? null;
|
||||
|
||||
// The lane picker sits above the empty branch, not inside the populated one:
|
||||
// narrowing to a lane with no stage records yet must still leave a way back.
|
||||
const lanePicker = data.lanes.length > 1 && (
|
||||
<select
|
||||
data-testid="pipeline-stats-lane-picker"
|
||||
aria-label={t("lanes.laneLabel")}
|
||||
className="max-w-[14rem] truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">{t("lanes.allLanes")}</option>
|
||||
{data.lanes.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.title || l.cwd}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t("lanes.empty")} <code>ccam lanes add --cwd $(pwd)</code>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{lanePicker}
|
||||
<p className="text-sm text-fg-muted">
|
||||
{laneId ? (
|
||||
t("lanes.emptyLane")
|
||||
) : (
|
||||
<>
|
||||
{t("lanes.empty")} <code>ccam lanes add --cwd $(pwd)</code>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +106,7 @@ export function LanePipelinePanel() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{lanePicker}
|
||||
{data.pipelines.length > 1 && (
|
||||
<select
|
||||
data-testid="pipeline-stats-picker"
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { LanePipelinePanel } from "../LanePipelinePanel";
|
||||
|
||||
const statsMock = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { lanes: { pipelineStats: () => statsMock() } } };
|
||||
return { ...actual, api: { lanes: { pipelineStats: (id?: number | null) => statsMock(id) } } };
|
||||
});
|
||||
|
||||
const node = (id: string, over: Record<string, number> = {}) => ({
|
||||
@@ -45,6 +45,10 @@ const STATS = {
|
||||
},
|
||||
{ id: "hotfix", name: "Hotfix", runs: 1, nodes: [node("patch", { entered: 1, passed: 1 })] },
|
||||
],
|
||||
lanes: [
|
||||
{ id: 1, title: "api lane", cwd: "/w/api" },
|
||||
{ id: 2, title: "", cwd: "/w/ui" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("LanePipelinePanel", () => {
|
||||
@@ -91,12 +95,42 @@ describe("LanePipelinePanel", () => {
|
||||
});
|
||||
|
||||
it("shows the CLI hint when nothing has been recorded", async () => {
|
||||
statsMock.mockResolvedValue({ pipelines: [] });
|
||||
statsMock.mockResolvedValue({ pipelines: [], lanes: [] });
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
expect(await screen.findByText(/ccam lanes add/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refetches narrowed to the lane picked, and falls back to a cwd for an untitled one", async () => {
|
||||
statsMock.mockResolvedValue(STATS);
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
const picker = await screen.findByTestId("pipeline-stats-lane-picker");
|
||||
expect(within(picker).getByText("/w/ui")).toBeInTheDocument();
|
||||
expect(statsMock).toHaveBeenLastCalledWith(null);
|
||||
|
||||
fireEvent.change(picker, { target: { value: "2" } });
|
||||
expect(statsMock).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
it("keeps the lane picker reachable when the picked lane recorded nothing", async () => {
|
||||
statsMock.mockResolvedValue(STATS);
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
// Without a picker in the empty branch, narrowing to a quiet lane would be
|
||||
// a one-way trip — no control left to select any other lane.
|
||||
statsMock.mockResolvedValue({ pipelines: [], lanes: STATS.lanes });
|
||||
fireEvent.change(await screen.findByTestId("pipeline-stats-lane-picker"), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
|
||||
// Wait for the empty branch to take over, then re-query: the refetch swaps
|
||||
// the whole subtree, so a handle grabbed before it is already detached.
|
||||
await waitFor(() => expect(screen.queryByTestId("pipeline-stat-plan")).not.toBeInTheDocument());
|
||||
expect(screen.getByTestId("pipeline-stats-lane-picker")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/ccam lanes add/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a failed fetch", async () => {
|
||||
statsMock.mockRejectedValue(new Error("stats unreachable"));
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
@@ -246,13 +246,16 @@
|
||||
"title": "Lane Pipeline Pass Rates",
|
||||
"subtitle": "How often each stage of your own pipelines is actually passed",
|
||||
"pipelineLabel": "Pipeline template",
|
||||
"laneLabel": "Lane",
|
||||
"allLanes": "All lanes",
|
||||
"runs_one": "{{count}} run recorded",
|
||||
"runs_other": "{{count}} runs recorded",
|
||||
"legendEvidence": "passed with evidence",
|
||||
"legendClaimed": "passed, no evidence",
|
||||
"legendFailed": "failed",
|
||||
"never": "never reached",
|
||||
"empty": "No lane has recorded a stage yet. Create one with"
|
||||
"empty": "No lane has recorded a stage yet. Create one with",
|
||||
"emptyLane": "This lane has not recorded a stage yet."
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Detected Workflow Patterns",
|
||||
|
||||
@@ -246,13 +246,16 @@
|
||||
"title": "Tỉ lệ pass các node pipeline",
|
||||
"subtitle": "Mỗi giai đoạn trong pipeline của bạn thực sự pass bao nhiêu lần",
|
||||
"pipelineLabel": "Mẫu pipeline",
|
||||
"laneLabel": "Lane",
|
||||
"allLanes": "Tất cả lane",
|
||||
"runs_one": "{{count}} lượt chạy được ghi nhận",
|
||||
"runs_other": "{{count}} lượt chạy được ghi nhận",
|
||||
"legendEvidence": "pass kèm bằng chứng",
|
||||
"legendClaimed": "pass, không bằng chứng",
|
||||
"legendFailed": "thất bại",
|
||||
"never": "chưa từng tới",
|
||||
"empty": "Chưa lane nào ghi nhận giai đoạn. Tạo lane bằng"
|
||||
"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."
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Mẫu quy trình phát hiện được",
|
||||
|
||||
@@ -1924,9 +1924,13 @@ export const api = {
|
||||
/**
|
||||
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
|
||||
* lane and every archived feature.
|
||||
* @returns `{ pipelines }` — see {@link PipelineStats}.
|
||||
* @param laneId Narrow to one lane and its archived features; omit for all.
|
||||
* @returns `{ pipelines, lanes }` — see {@link PipelineStats}.
|
||||
*/
|
||||
pipelineStats: () => request<PipelineStats>("/lanes/pipeline-stats"),
|
||||
pipelineStats: (laneId?: number | null) =>
|
||||
request<PipelineStats>(
|
||||
`/lanes/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/branches — a candidate source repo's local branches.
|
||||
* @param repo Absolute path to an existing git repository.
|
||||
|
||||
@@ -2490,6 +2490,8 @@ export interface PipelineStats {
|
||||
runs: number;
|
||||
nodes: PipelineNodeStat[];
|
||||
}[];
|
||||
/** Every lane, for the panel's lane picker — never narrowed by `?lane=`. */
|
||||
lanes: { id: number; title: string; cwd: string }[];
|
||||
}
|
||||
|
||||
/** Skill invocation counts and the per-plugin totals derived from them. */
|
||||
|
||||
@@ -10328,15 +10328,19 @@ exports[`screen snapshots > Workflows 1`] = `
|
||||
<div
|
||||
class="card p-4 flex-1"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
<div
|
||||
class="space-y-3"
|
||||
>
|
||||
No lane has recorded a stage yet. Create one with
|
||||
|
||||
<code>
|
||||
ccam lanes add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
No lane has recorded a stage yet. Create one with
|
||||
|
||||
<code>
|
||||
ccam lanes add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -389,7 +389,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
update: r({ lane: {} }),
|
||||
stage: r({ lane: {} }),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
pipelineStats: r({ pipelines: [] }),
|
||||
pipelineStats: r({ pipelines: [], lanes: [] }),
|
||||
// A realistic shape: `{}` would crash blockingReason on `blocked` if any
|
||||
// screen ever opened the destructive modal.
|
||||
preflight: r({
|
||||
|
||||
+13
-4
@@ -576,7 +576,11 @@ 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). No parameters; grouped by pipeline template, busiest first.
|
||||
feature). Grouped by pipeline template, busiest first.
|
||||
|
||||
**Query parameters:**
|
||||
- **`lane`** — lane id; narrows the aggregation to that lane and its own
|
||||
archived features. Omit (or pass `all`) for every lane.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -590,10 +594,13 @@ feature). No parameters; grouped by pipeline template, busiest first.
|
||||
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"lanes": [{ "id": 1, "title": "api lane", "cwd": "/w/api" }]
|
||||
}
|
||||
```
|
||||
|
||||
- **`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.
|
||||
- **`entered`** — stage records that resolve onto this node (aliases included).
|
||||
- **`failed`** — records whose `result` is `"fail"`; **`passed`** is the rest,
|
||||
@@ -602,8 +609,10 @@ feature). No parameters; grouped by pipeline template, busiest first.
|
||||
from `passed` because a claim and a proof are not the same fact — the same
|
||||
distinction the pipeline map draws between `done` and `passed-no-evidence`.
|
||||
|
||||
**Status codes:** **200** always (an empty `pipelines` array when no lane has
|
||||
recorded a stage yet).
|
||||
**Status codes:**
|
||||
- **200** — success (an empty `pipelines` array when no lane has recorded a
|
||||
stage yet, or when `lane` names an id that no longer exists)
|
||||
- **400** `{ "error": { "code": "EBADLANE", "message": "lane must be an id" } }` — `lane` is not a positive integer
|
||||
|
||||
### Workflows
|
||||
|
||||
|
||||
@@ -482,6 +482,11 @@ feature, so without them the history would reset every time a lane starts new
|
||||
work. The Workflows page renders this as one bar per node (see
|
||||
`client/src/components/workflows/LanePipelinePanel.tsx`).
|
||||
|
||||
`?lane=<id>` narrows the same aggregation to one lane and its own archived
|
||||
features, which is what the panel's lane picker sends. The `lanes` array in the
|
||||
response is never narrowed — it populates that picker, so it has to keep
|
||||
offering every other lane even while the counts show only one.
|
||||
|
||||
## Attaching a real terminal to a lane
|
||||
|
||||
`ccam lanes shell` attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this lane (`ccam-lane-<id>`), creating it if it doesn't exist yet. Type `claude` inside it like any normal terminal session — the dashboard's Workspace terminal view is just another client attached to the same tmux session, so both stay in sync live.
|
||||
|
||||
@@ -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