57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
1883 lines
74 KiB
Markdown
1883 lines
74 KiB
Markdown
# Lanes + Pipeline Phase View Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Give CCAM a durable "lane" entity (one per working directory / agent) plus a Shipyard-style pipeline map that shows which stage and which gates each Claude Code agent has finished, with per-lane liveness and start/stop/resume control.
|
|
|
|
**Architecture:** Approach A — CCAM stays a monitor + control panel; it does NOT orchestrate. The Claude session itself drives its pipeline and *declares* its stage through `ccam stage <name>` (a new CLI subcommand hitting `POST /api/lanes/:id/stage`), exactly the way Shipyard's skills call `bin/state.sh N set stage=…`. A lane is bound to a session automatically by longest-prefix match on the hook payload's `cwd`, so un-instrumented sessions still appear (stage inferred from `workflows.phases` / TodoWrite, never invented). Pipeline shape is a JSON template on disk, not hardcoded coordinates.
|
|
|
|
**Tech Stack:** Node 18+, Express, better-sqlite3, `node:test` (server), React 18 + TypeScript + Vite + Tailwind, Vitest + Testing Library (client), existing `server/websocket.js` broadcast.
|
|
|
|
## Global Constraints
|
|
|
|
- Fork lives at `~/MyDrive/Projects/ResearchAndDevelopment/ccam-lanes`, remote `upstream` = `https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor`, forked at `94bfdde` (2026-07-26). Keep the fork private; never push to `upstream`.
|
|
- Every `.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css` file created or modified MUST start with a file overview block plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. This is enforced by `bash .claude/skills/file-headers/scripts/check-headers.sh` whose `AUTHOR_MARK` was rewritten to this name on 2026-07-27 (fork is internal, never published). Do not substitute a different author line. - Preserve existing behavior. Additive schema only: new tables and `ALTER TABLE … ADD COLUMN` guarded by a `try { SELECT col } catch { ALTER }` probe, matching `server/db.js:412-418`.
|
|
- WebSocket message types are append-only. Add `lane_update`; do not rename or repurpose existing types.
|
|
- The hook ingest path must stay fail-safe and non-blocking: any lane logic added to `server/routes/hooks.js` runs inside `try { … } catch { /* never block a hook */ }`.
|
|
- Destructive lane actions (`reset`, `remove`) require an explicit confirmation flag in the request body; they never run implicitly.
|
|
- Route handlers that spawn processes reuse the loopback/same-origin guard. Do not weaken it.
|
|
- Server tests: `npm run test:server`. Client tests: `npm run test:client`. Per-screen snapshots live in `client/src/pages/__tests__/screens.snapshot.test.tsx`; regenerate intentionally with `cd client && npx vitest run -u`, never blindly.
|
|
- User-visible strings go through i18n (`client/src/i18n`), not string literals in components.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Create**
|
|
- `server/lib/pipelines.js` — pipeline templates + stage→node mapping. Pure functions, no DB.
|
|
- `server/lib/lanes.js` — lane CRUD, stage transitions, cwd resolution, liveness. Owns all SQL for `lanes`.
|
|
- `server/routes/lanes.js` — HTTP surface over `server/lib/lanes.js` + the action layer.
|
|
- `server/data/pipelines/default.json` — the built-in pipeline template.
|
|
- `server/__tests__/lanes-lib.test.js` — unit tests for `lib/pipelines.js` + `lib/lanes.js`.
|
|
- `server/__tests__/lanes-api.test.js` — HTTP tests for `/api/lanes`, hook binding, actions.
|
|
- `client/src/components/lanes/PipelineMap.tsx` — the stage graph.
|
|
- `client/src/components/lanes/LaneCard.tsx` — one lane's card.
|
|
- `client/src/pages/Lanes.tsx` — page: header counters + map + card grid.
|
|
- `client/src/components/lanes/__tests__/PipelineMap.test.tsx` — node-state rendering test.
|
|
|
|
**Modify**
|
|
- `server/db.js` — `lanes` table + migration probe only. Lane SQL is prepared inside `server/lib/lanes.js` (that module "owns all SQL for `lanes`"), NOT added to the shared `stmts` dictionary.
|
|
- `server/index.js` — mount `app.use("/api/lanes", lanesRouter)`.
|
|
- `server/routes/hooks.js` — bind session→lane by `cwd`; set `needs_action` on `Notification`.
|
|
- `server/routes/run.js` — export the same-origin guard for reuse (no behavior change).
|
|
- `bin/ccam.js` — `ccam stage` and `ccam lanes` subcommands.
|
|
- `client/src/lib/api.ts` — `api.lanes.*` + `Lane` / `PipelineNode` / `LaneNodeState` types.
|
|
- `client/src/App.tsx` — `/lanes` route.
|
|
- `client/src/components/Sidebar.tsx` — nav entry.
|
|
- `client/src/i18n/*.json` — lane strings.
|
|
|
|
---
|
|
|
|
## Task 1: Pipeline template + node-state derivation
|
|
|
|
Pure logic first — no DB, no HTTP. Everything downstream reads node state from here.
|
|
|
|
**Files:**
|
|
- Create: `server/data/pipelines/default.json`
|
|
- Create: `server/lib/pipelines.js`
|
|
- Test: `server/__tests__/lanes-lib.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: nothing.
|
|
- Produces:
|
|
- `DEFAULT_PIPELINE_ID = "default"`
|
|
- `listPipelines(): Array<{id, name, nodes}>`
|
|
- `getPipeline(id): {id, name, nodes: Array<{id,label,icon,aliases:string[],gate:boolean}>}` — falls back to the default template for an unknown id, never throws.
|
|
- `phaseIdx(pipeline, stage): number` — index of the node whose `id` or `aliases` contains `stage`; `-1` if unknown.
|
|
- `nodeStates(pipeline, lane): Array<{id,label,icon,gate,state}>` where `state ∈ "done" | "current" | "passed-no-evidence" | "failed" | "pending"`.
|
|
- `progressPct(pipeline, lane): number` — 0-100 integer.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `server/__tests__/lanes-lib.test.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file Unit tests for the lane pipeline template helpers (server/lib/pipelines.js):
|
|
* stage→node resolution through aliases, the five node states rendered by the
|
|
* pipeline map, and progress percentage.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const {
|
|
DEFAULT_PIPELINE_ID,
|
|
getPipeline,
|
|
listPipelines,
|
|
phaseIdx,
|
|
nodeStates,
|
|
progressPct,
|
|
} = require("../lib/pipelines");
|
|
|
|
describe("pipelines", () => {
|
|
it("exposes a default template and falls back to it for unknown ids", () => {
|
|
const def = getPipeline(DEFAULT_PIPELINE_ID);
|
|
assert.ok(def.nodes.length > 3);
|
|
assert.equal(getPipeline("does-not-exist").id, DEFAULT_PIPELINE_ID);
|
|
assert.ok(listPipelines().some((p) => p.id === DEFAULT_PIPELINE_ID));
|
|
});
|
|
|
|
it("resolves a stage through node id and through aliases", () => {
|
|
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
|
assert.equal(phaseIdx(p, "plan"), p.nodes.findIndex((n) => n.id === "plan"));
|
|
assert.equal(phaseIdx(p, "planning"), phaseIdx(p, "plan"));
|
|
assert.equal(phaseIdx(p, "totally-unknown-stage"), -1);
|
|
});
|
|
|
|
it("marks the current stage current, recorded-with-evidence done, recorded-without amber", () => {
|
|
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
|
const lane = {
|
|
stage: "review",
|
|
stages: {
|
|
plan: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" },
|
|
implement: { enteredAt: "2026-07-27T01:00:00Z", evidence: null },
|
|
review: { enteredAt: "2026-07-27T02:00:00Z", evidence: null },
|
|
},
|
|
};
|
|
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
|
assert.equal(byId.plan, "done");
|
|
assert.equal(byId.implement, "passed-no-evidence");
|
|
assert.equal(byId.review, "current");
|
|
assert.equal(byId.done, "pending");
|
|
});
|
|
|
|
it("marks a failed stage failed even when it is the current stage", () => {
|
|
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
|
const lane = { stage: "gate", stages: { gate: { enteredAt: "x", result: "fail" } } };
|
|
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
|
assert.equal(byId.gate, "failed");
|
|
});
|
|
|
|
it("treats skipped earlier nodes as passed-without-evidence, not done", () => {
|
|
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
|
const lane = { stage: "review", stages: { review: { enteredAt: "x" } } };
|
|
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
|
assert.equal(byId.plan, "passed-no-evidence");
|
|
});
|
|
|
|
it("computes progress from node position, 0 for an unknown stage", () => {
|
|
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
|
assert.equal(progressPct(p, { stage: p.nodes[0].id, stages: {} }), 0);
|
|
assert.equal(progressPct(p, { stage: p.nodes[p.nodes.length - 1].id, stages: {} }), 100);
|
|
assert.equal(progressPct(p, { stage: "nope", stages: {} }), 0);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lanes-lib.test.js`
|
|
Expected: FAIL — `Cannot find module '../lib/pipelines'`.
|
|
|
|
- [ ] **Step 3: Write the template**
|
|
|
|
Create `server/data/pipelines/default.json`:
|
|
|
|
```json
|
|
{
|
|
"id": "default",
|
|
"name": "Default feature pipeline",
|
|
"nodes": [
|
|
{ "id": "intake", "label": "intake", "icon": "📝", "gate": false, "aliases": ["assigned", "claimed", "start"] },
|
|
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false, "aliases": ["planning", "brainstorm", "design"] },
|
|
{ "id": "implement", "label": "implement", "icon": "🛠", "gate": false, "aliases": ["implementing", "coding", "build"] },
|
|
{ "id": "tests", "label": "tests", "icon": "🧪", "gate": true, "aliases": ["testing", "unit", "gates", "pre-push-gate"] },
|
|
{ "id": "review", "label": "review", "icon": "👀", "gate": true, "aliases": ["reviewing", "code-review", "self-review"] },
|
|
{ "id": "gate", "label": "gate", "icon": "🚦", "gate": true, "aliases": ["verify", "verification", "sr-gate", "gate-blocked"] },
|
|
{ "id": "ship", "label": "ship", "icon": "🔀", "gate": false, "aliases": ["pr", "pr-open", "publishing", "commit", "push"] },
|
|
{ "id": "done", "label": "done", "icon": "✅", "gate": false, "aliases": ["complete", "completed", "merged"] }
|
|
]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Write the module**
|
|
|
|
Create `server/lib/pipelines.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file Pipeline templates for lanes. A template is a plain JSON list of nodes
|
|
* (`server/data/pipelines/*.json` plus any override dropped in
|
|
* `DASHBOARD_PIPELINES_DIR`); this module resolves a lane's declared stage onto
|
|
* a node through per-node `aliases`, and derives the five render states the
|
|
* pipeline map draws. Pure functions — no DB, no I/O beyond the one-time
|
|
* template load, so it stays trivially testable.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const DEFAULT_PIPELINE_ID = "default";
|
|
const BUILTIN_DIR = path.join(__dirname, "..", "data", "pipelines");
|
|
|
|
/** Load every template once. A malformed file is skipped, never fatal. */
|
|
function loadAll() {
|
|
const dirs = [BUILTIN_DIR];
|
|
if (process.env.DASHBOARD_PIPELINES_DIR) dirs.push(process.env.DASHBOARD_PIPELINES_DIR);
|
|
const out = new Map();
|
|
for (const dir of dirs) {
|
|
let files = [];
|
|
try {
|
|
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
} catch {
|
|
continue; // dir absent — fine
|
|
}
|
|
for (const f of files) {
|
|
try {
|
|
const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue;
|
|
doc.nodes = doc.nodes.map((n) => ({
|
|
id: n.id,
|
|
label: n.label || n.id,
|
|
icon: n.icon || "",
|
|
gate: !!n.gate,
|
|
aliases: Array.isArray(n.aliases) ? n.aliases : [],
|
|
}));
|
|
out.set(doc.id, doc); // later dir wins — user override beats builtin
|
|
} catch {
|
|
/* skip malformed template */
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
let cache = null;
|
|
function templates() {
|
|
if (!cache) cache = loadAll();
|
|
return cache;
|
|
}
|
|
|
|
/** Test/dev helper: forget the cached templates so a new file is picked up. */
|
|
function reload() {
|
|
cache = null;
|
|
}
|
|
|
|
function listPipelines() {
|
|
return [...templates().values()];
|
|
}
|
|
|
|
/** Never throws: an unknown id yields the default template. */
|
|
function getPipeline(id) {
|
|
const t = templates();
|
|
return t.get(id) || t.get(DEFAULT_PIPELINE_ID);
|
|
}
|
|
|
|
/** Index of the node matching `stage` by id or alias; -1 when unknown. */
|
|
function phaseIdx(pipeline, stage) {
|
|
if (!stage) return -1;
|
|
const s = String(stage).toLowerCase();
|
|
return pipeline.nodes.findIndex((n) => n.id.toLowerCase() === s || n.aliases.some((a) => a.toLowerCase() === s));
|
|
}
|
|
|
|
/**
|
|
* Render state per node:
|
|
* failed — the stage recorded result "fail"
|
|
* current — the lane's current stage
|
|
* done — recorded AND carries evidence (an artifact, not a claim)
|
|
* passed-no-evidence — recorded without evidence, or implicitly skipped past
|
|
* pending — not reached
|
|
*/
|
|
function nodeStates(pipeline, lane) {
|
|
const stages = lane.stages || {};
|
|
const cur = phaseIdx(pipeline, lane.stage);
|
|
return pipeline.nodes.map((n, i) => {
|
|
const rec = stages[n.id];
|
|
let state;
|
|
if (rec && rec.result === "fail") state = "failed";
|
|
else if (i === cur) state = "current";
|
|
else if (rec) state = rec.evidence ? "done" : "passed-no-evidence";
|
|
else if (cur > -1 && i < cur) state = "passed-no-evidence";
|
|
else state = "pending";
|
|
return { id: n.id, label: n.label, icon: n.icon, gate: n.gate, state };
|
|
});
|
|
}
|
|
|
|
function progressPct(pipeline, lane) {
|
|
const i = phaseIdx(pipeline, lane.stage);
|
|
if (i < 0) return 0;
|
|
return Math.round((i / (pipeline.nodes.length - 1)) * 100);
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_PIPELINE_ID,
|
|
listPipelines,
|
|
getPipeline,
|
|
phaseIdx,
|
|
nodeStates,
|
|
progressPct,
|
|
reload,
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `node --test server/__tests__/lanes-lib.test.js`
|
|
Expected: PASS, 6 tests.
|
|
|
|
- [ ] **Step 6: Header audit + commit**
|
|
|
|
Run: `bash .claude/skills/file-headers/scripts/check-headers.sh`
|
|
Expected: no failures for the new files.
|
|
|
|
```bash
|
|
git add server/lib/pipelines.js server/data/pipelines/default.json server/__tests__/lanes-lib.test.js
|
|
git commit -m "feat(lanes): pipeline templates and node-state derivation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: `lanes` table + lane library
|
|
|
|
**Files:**
|
|
- Modify: `server/db.js` (append to the schema block that ends near `server/db.js:400`, and to `stmts` at `server/db.js:953`)
|
|
- Create: `server/lib/lanes.js`
|
|
- Test: `server/__tests__/lanes-lib.test.js` (append a second `describe`)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `getPipeline`, `nodeStates`, `progressPct` from Task 1; `{ db }` from `server/db.js`.
|
|
- Produces:
|
|
- `createLane({title, cwd, branch, pipeline}): Lane`
|
|
- `listLanes(): Lane[]`, `getLane(id): Lane | null`
|
|
- `updateLane(id, patch): Lane` — patch keys limited to `title, branch, pipeline, status, gate_decision, ci_status, needs_action, links, notes, session_id, run_id`
|
|
- `deleteLane(id): boolean`
|
|
- `setStage(id, {stage, status, evidence, note, result}): Lane` — bumps `stage_since` only when `stage` actually changes; writes the `stages` record `{enteredAt, evidence, result}`
|
|
- `resolveLaneByCwd(cwd): Lane | null` — longest matching `cwd` prefix on a path boundary
|
|
- `clearLane(id): Lane` — resets stage/status/gate/ci/needs_action/stages, keeps title/cwd/branch
|
|
- `classifyLiveness({status, stage, ageSec}, deadSec): "active" | "idle" | "dead"`
|
|
- `lanePayload(lane, ageSec): object` — the row plus `pipeline_nodes`, `progress`, `liveness`, `stage_seconds`
|
|
- `DEAD_SEC` — `Number(process.env.LANE_DEAD_SEC || 300)`
|
|
|
|
- [ ] **Step 1: Write the failing test (append to `server/__tests__/lanes-lib.test.js`)**
|
|
|
|
Add at the top of the file, before the existing `require("../lib/pipelines")`:
|
|
|
|
```js
|
|
const os = require("node:os");
|
|
const pathMod = require("node:path");
|
|
process.env.DASHBOARD_DB_PATH = pathMod.join(
|
|
os.tmpdir(),
|
|
`dashboard-lanes-lib-${Date.now()}-${process.pid}.db`,
|
|
);
|
|
```
|
|
|
|
Append at the end of the file:
|
|
|
|
```js
|
|
const lanes = require("../lib/lanes");
|
|
|
|
describe("lanes lib", () => {
|
|
it("creates, lists, updates and deletes a lane", () => {
|
|
const l = lanes.createLane({ title: "Feature A", cwd: "/tmp/wt/a", branch: "feat/a" });
|
|
assert.equal(l.title, "Feature A");
|
|
assert.equal(l.stage, "idle");
|
|
assert.equal(lanes.getLane(l.id).cwd, "/tmp/wt/a");
|
|
assert.ok(lanes.listLanes().length >= 1);
|
|
assert.equal(lanes.updateLane(l.id, { ci_status: "green" }).ci_status, "green");
|
|
assert.equal(lanes.deleteLane(l.id), true);
|
|
assert.equal(lanes.getLane(l.id), null);
|
|
});
|
|
|
|
it("bumps stage_since only when the stage actually changes", async () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt/b" });
|
|
const a = lanes.setStage(l.id, { stage: "plan" });
|
|
await new Promise((r) => setTimeout(r, 1100));
|
|
const b = lanes.setStage(l.id, { stage: "plan", note: "still planning" });
|
|
assert.equal(a.stage_since, b.stage_since);
|
|
const c = lanes.setStage(l.id, { stage: "implement" });
|
|
assert.notEqual(c.stage_since, b.stage_since);
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("records evidence per stage so the map can tell done from amber", () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt/c" });
|
|
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
|
|
const after = lanes.setStage(l.id, { stage: "implement" });
|
|
assert.equal(after.stages.plan.evidence, "docs/plan.md");
|
|
assert.ok(after.stages.plan.enteredAt);
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("resolves a lane from a session cwd by longest path-boundary prefix", () => {
|
|
const outer = lanes.createLane({ cwd: "/tmp/wt" });
|
|
const inner = lanes.createLane({ cwd: "/tmp/wt/inner" });
|
|
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/inner/src").id, inner.id);
|
|
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/other").id, outer.id);
|
|
assert.equal(lanes.resolveLaneByCwd("/tmp/wt-sibling"), null);
|
|
assert.equal(lanes.resolveLaneByCwd(null), null);
|
|
lanes.deleteLane(inner.id);
|
|
lanes.deleteLane(outer.id);
|
|
});
|
|
|
|
it("classifies liveness: silent watcher is dead, silent idle lane is not", () => {
|
|
const d = 300;
|
|
assert.equal(lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 10 }, d), "active");
|
|
assert.equal(lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 999 }, d), "dead");
|
|
assert.equal(lanes.classifyLiveness({ status: "idle", stage: "watching-pr", ageSec: 999 }, d), "dead");
|
|
assert.equal(lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: 99999 }, d), "idle");
|
|
assert.equal(lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: null }, d), "idle");
|
|
});
|
|
|
|
it("payload carries node states and progress", () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt/d" });
|
|
lanes.setStage(l.id, { stage: "review" });
|
|
const p = lanes.lanePayload(lanes.getLane(l.id), 5);
|
|
assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current");
|
|
assert.ok(p.progress > 0 && p.progress < 100);
|
|
assert.equal(p.liveness, "idle");
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lanes-lib.test.js`
|
|
Expected: FAIL — `Cannot find module '../lib/lanes'`.
|
|
|
|
- [ ] **Step 3: Add the schema**
|
|
|
|
In `server/db.js`, inside the same `db.exec(\`…\`)` block that declares `workflows` (ends just before `CREATE INDEX IF NOT EXISTS idx_workflows_session`), append:
|
|
|
|
```sql
|
|
-- A lane is a durable unit of parallel work: one working directory driven by
|
|
-- (over time) many Claude Code sessions. Sessions are ephemeral and keyed by
|
|
-- session_id; a lane survives restarts, which is what makes a pipeline view
|
|
-- possible. `stages` is a JSON map stageId -> {enteredAt, evidence, result}:
|
|
-- one column instead of a history table, because the map only ever reads the
|
|
-- latest record per stage.
|
|
CREATE TABLE IF NOT EXISTS lanes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
cwd TEXT NOT NULL UNIQUE,
|
|
branch TEXT,
|
|
pipeline TEXT NOT NULL DEFAULT 'default',
|
|
session_id TEXT,
|
|
run_id TEXT,
|
|
stage TEXT NOT NULL DEFAULT 'idle',
|
|
stage_since TEXT,
|
|
status TEXT NOT NULL DEFAULT 'idle',
|
|
gate_decision TEXT,
|
|
ci_status TEXT,
|
|
needs_action TEXT,
|
|
links TEXT NOT NULL DEFAULT '{}',
|
|
stages TEXT NOT NULL DEFAULT '{}',
|
|
notes TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_lanes_session ON lanes(session_id);
|
|
```
|
|
|
|
- [ ] **Step 4: Write the lane library**
|
|
|
|
Create `server/lib/lanes.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file Lane storage and lifecycle. A lane is a durable unit of parallel agent
|
|
* work — one working directory, many sessions over time — so the dashboard can
|
|
* show a pipeline that survives session restarts. This module owns every SQL
|
|
* statement touching the `lanes` table, resolves an incoming hook's `cwd` onto a
|
|
* lane, records stage transitions (with `stage_since` semantics), and classifies
|
|
* liveness the way Shipyard does: a silent watcher is dead, a silent idle lane
|
|
* is merely at rest.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { db } = require("../db");
|
|
const { getPipeline, nodeStates, progressPct } = require("./pipelines");
|
|
|
|
const DEAD_SEC = Number(process.env.LANE_DEAD_SEC || 300);
|
|
|
|
/** Stages whose whole job is to wait — silence here means the loop died. */
|
|
const WATCH_STAGE_RE = /watch|poll/i;
|
|
|
|
const PATCHABLE = new Set([
|
|
"title", "branch", "pipeline", "status", "gate_decision",
|
|
"ci_status", "needs_action", "links", "notes", "session_id", "run_id",
|
|
]);
|
|
|
|
const nowIso = () => new Date().toISOString();
|
|
|
|
function hydrate(row) {
|
|
if (!row) return null;
|
|
let stages = {};
|
|
let links = {};
|
|
try { stages = JSON.parse(row.stages || "{}"); } catch { /* corrupt blob -> empty */ }
|
|
try { links = JSON.parse(row.links || "{}"); } catch { /* corrupt blob -> empty */ }
|
|
return { ...row, stages, links };
|
|
}
|
|
|
|
function createLane({ title = "", cwd, branch = null, pipeline = "default" } = {}) {
|
|
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
|
|
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
|
}
|
|
const info = db
|
|
.prepare("INSERT INTO lanes (title, cwd, branch, pipeline, stage_since) VALUES (?, ?, ?, ?, ?)")
|
|
.run(title, cwd.replace(/\/+$/, ""), branch, pipeline, nowIso());
|
|
return getLane(info.lastInsertRowid);
|
|
}
|
|
|
|
function listLanes() {
|
|
return db.prepare("SELECT * FROM lanes ORDER BY id ASC").all().map(hydrate);
|
|
}
|
|
|
|
function getLane(id) {
|
|
return hydrate(db.prepare("SELECT * FROM lanes WHERE id = ?").get(id));
|
|
}
|
|
|
|
function updateLane(id, patch = {}) {
|
|
const cols = [];
|
|
const vals = [];
|
|
for (const [k, v] of Object.entries(patch)) {
|
|
if (!PATCHABLE.has(k)) continue;
|
|
cols.push(`${k} = ?`);
|
|
vals.push(k === "links" && typeof v === "object" ? JSON.stringify(v) : v);
|
|
}
|
|
if (cols.length) {
|
|
cols.push("updated_at = ?");
|
|
vals.push(nowIso(), id);
|
|
db.prepare(`UPDATE lanes SET ${cols.join(", ")} WHERE id = ?`).run(...vals);
|
|
}
|
|
return getLane(id);
|
|
}
|
|
|
|
function deleteLane(id) {
|
|
return db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
|
|
}
|
|
|
|
/**
|
|
* Record a stage transition. `stage_since` moves ONLY when the stage value
|
|
* actually changes, so the UI's time-on-phase is real; a re-report of the same
|
|
* stage (a heartbeat, an added note) leaves it alone.
|
|
*/
|
|
function setStage(id, { stage, status, evidence, note, result } = {}) {
|
|
const lane = getLane(id);
|
|
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
|
|
const next = stage || lane.stage;
|
|
const stages = { ...lane.stages };
|
|
const prev = stages[next] || {};
|
|
stages[next] = {
|
|
enteredAt: next === lane.stage && prev.enteredAt ? prev.enteredAt : nowIso(),
|
|
evidence: evidence !== undefined ? evidence : prev.evidence || null,
|
|
result: result !== undefined ? result : prev.result || null,
|
|
};
|
|
db.prepare(
|
|
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
).run(
|
|
next,
|
|
next === lane.stage ? lane.stage_since || nowIso() : nowIso(),
|
|
status || lane.status,
|
|
JSON.stringify(stages),
|
|
note !== undefined ? note : lane.notes,
|
|
nowIso(),
|
|
id,
|
|
);
|
|
return getLane(id);
|
|
}
|
|
|
|
function clearLane(id) {
|
|
db.prepare(
|
|
`UPDATE lanes SET stage = 'idle', stage_since = ?, status = 'idle', gate_decision = NULL,
|
|
ci_status = NULL, needs_action = NULL, stages = '{}', notes = NULL, run_id = NULL,
|
|
updated_at = ? WHERE id = ?`,
|
|
).run(nowIso(), nowIso(), id);
|
|
return getLane(id);
|
|
}
|
|
|
|
/**
|
|
* Longest path-boundary prefix match. `/tmp/wt` must NOT capture
|
|
* `/tmp/wt-sibling`, and a nested lane must beat its parent.
|
|
*/
|
|
function resolveLaneByCwd(cwd) {
|
|
if (!cwd || typeof cwd !== "string") return null;
|
|
const target = cwd.replace(/\/+$/, "");
|
|
let best = null;
|
|
for (const lane of listLanes()) {
|
|
const base = lane.cwd.replace(/\/+$/, "");
|
|
if (target === base || target.startsWith(`${base}/`)) {
|
|
if (!best || base.length > best.cwd.length) best = lane;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
|
|
const expectLive = status === "running" || status === "provisioning" || WATCH_STAGE_RE.test(stage || "");
|
|
if (!expectLive) return "idle";
|
|
if (ageSec !== null && ageSec !== undefined && ageSec > deadSec) return "dead";
|
|
return "active";
|
|
}
|
|
|
|
function lanePayload(lane, ageSec = null) {
|
|
const pipeline = getPipeline(lane.pipeline);
|
|
const since = lane.stage_since ? Date.parse(lane.stage_since) : NaN;
|
|
return {
|
|
...lane,
|
|
pipeline_name: pipeline.name,
|
|
pipeline_nodes: nodeStates(pipeline, lane),
|
|
progress: progressPct(pipeline, lane),
|
|
stage_seconds: Number.isNaN(since) ? null : Math.max(0, Math.round((Date.now() - since) / 1000)),
|
|
last_event_seconds: ageSec,
|
|
liveness: classifyLiveness({ status: lane.status, stage: lane.stage, ageSec }, DEAD_SEC),
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
DEAD_SEC,
|
|
createLane,
|
|
listLanes,
|
|
getLane,
|
|
updateLane,
|
|
deleteLane,
|
|
setStage,
|
|
clearLane,
|
|
resolveLaneByCwd,
|
|
classifyLiveness,
|
|
lanePayload,
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run: `node --test server/__tests__/lanes-lib.test.js`
|
|
Expected: PASS, 12 tests.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add server/db.js server/lib/lanes.js server/__tests__/lanes-lib.test.js
|
|
git commit -m "feat(lanes): lanes table and lane lifecycle library"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: REST surface + WebSocket broadcast
|
|
|
|
**Files:**
|
|
- Create: `server/routes/lanes.js`
|
|
- Modify: `server/index.js` (mount next to `app.use("/api/run", runRouter)` at `server/index.js:101`)
|
|
- Test: `server/__tests__/lanes-api.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: everything exported by `server/lib/lanes.js`; `broadcast` from `server/websocket.js`; `listPipelines` from `server/lib/pipelines.js`.
|
|
- Produces:
|
|
- `GET /api/lanes` → `{ lanes: LanePayload[], counts: {total, running, needs_you, dead} }`
|
|
- `GET /api/lanes/pipelines` → `{ pipelines: [{id, name, nodes}] }`
|
|
- `GET /api/lanes/:id` → `{ lane: LanePayload }`
|
|
- `POST /api/lanes` `{title, cwd, branch?, pipeline?}` → 201 `{ lane }`
|
|
- `PATCH /api/lanes/:id` → `{ lane }`
|
|
- `POST /api/lanes/:id/stage` `{stage, status?, evidence?, note?, result?}` → `{ lane }`
|
|
- `DELETE /api/lanes/:id` → `{ ok: true }`
|
|
- WS `lane_update` on every mutation. Payload is `{ lane: LanePayload }` for create/update/stage, and `{ removed: <id> }` for delete — a deleted lane has no payload to send, and `Lanes.tsx` (Task 7) branches on `removed` to refetch the counters. The asymmetry is deliberate.
|
|
- Exported helper `broadcastLane(id)` for reuse by `routes/hooks.js` (Task 4).
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `server/__tests__/lanes-api.test.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file HTTP tests for /api/lanes: CRUD, stage reporting, the aggregate
|
|
* counters the header badges read, and the last-event age that drives liveness.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("path");
|
|
const os = require("os");
|
|
const http = require("http");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
function request(method, urlPath, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlPath, BASE);
|
|
const payload = body ? JSON.stringify(body) : null;
|
|
const req = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname + url.search,
|
|
method,
|
|
headers: payload
|
|
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
|
|
: {},
|
|
},
|
|
(res) => {
|
|
let data = "";
|
|
res.on("data", (c) => (data += c));
|
|
res.on("end", () => {
|
|
let parsed = null;
|
|
try { parsed = JSON.parse(data); } catch { /* non-JSON body */ }
|
|
resolve({ status: res.statusCode, body: parsed });
|
|
});
|
|
},
|
|
);
|
|
req.on("error", reject);
|
|
if (payload) req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
before(async () => {
|
|
const app = createApp();
|
|
server = await startServer(app, 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
after(() => server && server.close());
|
|
|
|
describe("/api/lanes", () => {
|
|
let laneId;
|
|
|
|
it("creates a lane", async () => {
|
|
const r = await request("POST", "/api/lanes", { title: "Lane A", cwd: "/tmp/lane-api-a" });
|
|
assert.equal(r.status, 201);
|
|
assert.equal(r.body.lane.title, "Lane A");
|
|
assert.equal(r.body.lane.stage, "idle");
|
|
laneId = r.body.lane.id;
|
|
});
|
|
|
|
it("rejects a relative cwd", async () => {
|
|
const r = await request("POST", "/api/lanes", { cwd: "relative/path" });
|
|
assert.equal(r.status, 400);
|
|
});
|
|
|
|
it("rejects a duplicate cwd", async () => {
|
|
const r = await request("POST", "/api/lanes", { cwd: "/tmp/lane-api-a" });
|
|
assert.equal(r.status, 409);
|
|
});
|
|
|
|
it("reports a stage and returns node states", async () => {
|
|
const r = await request("POST", `/api/lanes/${laneId}/stage`, {
|
|
stage: "review",
|
|
status: "running",
|
|
evidence: null,
|
|
});
|
|
assert.equal(r.status, 200);
|
|
const review = r.body.lane.pipeline_nodes.find((n) => n.id === "review");
|
|
assert.equal(review.state, "current");
|
|
assert.ok(r.body.lane.progress > 0);
|
|
});
|
|
|
|
it("404s on an unknown lane", async () => {
|
|
const r = await request("POST", "/api/lanes/99999/stage", { stage: "plan" });
|
|
assert.equal(r.status, 404);
|
|
});
|
|
|
|
it("lists lanes with counters", async () => {
|
|
const r = await request("GET", "/api/lanes");
|
|
assert.equal(r.status, 200);
|
|
assert.ok(r.body.lanes.length >= 1);
|
|
assert.equal(r.body.counts.total, r.body.lanes.length);
|
|
assert.equal(typeof r.body.counts.running, "number");
|
|
assert.equal(typeof r.body.counts.needs_you, "number");
|
|
});
|
|
|
|
it("exposes pipeline templates", async () => {
|
|
const r = await request("GET", "/api/lanes/pipelines");
|
|
assert.equal(r.status, 200);
|
|
assert.ok(r.body.pipelines.some((p) => p.id === "default"));
|
|
});
|
|
|
|
it("patches and deletes", async () => {
|
|
const p = await request("PATCH", `/api/lanes/${laneId}`, { ci_status: "green" });
|
|
assert.equal(p.body.lane.ci_status, "green");
|
|
const d = await request("DELETE", `/api/lanes/${laneId}`);
|
|
assert.equal(d.status, 200);
|
|
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lanes-api.test.js`
|
|
Expected: FAIL — the create call 404s because no router is mounted.
|
|
|
|
- [ ] **Step 3: Write the router**
|
|
|
|
Create `server/routes/lanes.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file Express router for lanes — the durable per-working-directory unit of
|
|
* parallel agent work. Read endpoints join each lane with its most recent event
|
|
* timestamp so liveness can be computed without a separate heartbeat, and every
|
|
* mutation re-broadcasts the lane over the existing WebSocket as `lane_update`.
|
|
* Orchestration is deliberately absent: the driving Claude session declares its
|
|
* own stage (`POST /:id/stage`); the dashboard never guesses a transition.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const { db } = require("../db");
|
|
const lanesLib = require("../lib/lanes");
|
|
const { listPipelines } = require("../lib/pipelines");
|
|
const { broadcast } = require("../websocket");
|
|
|
|
const router = Router();
|
|
|
|
/** Seconds since this lane's session last emitted an event; null if never. */
|
|
function lastEventAge(lane) {
|
|
if (!lane.session_id) return null;
|
|
const row = db
|
|
.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?")
|
|
.get(lane.session_id);
|
|
if (!row || !row.last) return null;
|
|
const t = Date.parse(row.last);
|
|
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
}
|
|
|
|
function payload(lane) {
|
|
return lanesLib.lanePayload(lane, lastEventAge(lane));
|
|
}
|
|
|
|
/** Push the current state of one lane to every connected client. */
|
|
function broadcastLane(id) {
|
|
const lane = lanesLib.getLane(id);
|
|
if (lane) broadcast("lane_update", { lane: payload(lane) });
|
|
}
|
|
|
|
router.get("/", (_req, res) => {
|
|
const lanes = lanesLib.listLanes().map(payload);
|
|
res.json({
|
|
lanes,
|
|
counts: {
|
|
total: lanes.length,
|
|
running: lanes.filter((l) => l.status === "running").length,
|
|
needs_you: lanes.filter((l) => l.needs_action).length,
|
|
dead: lanes.filter((l) => l.liveness === "dead").length,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Registered before "/:id" so the literal path is not swallowed by the param.
|
|
router.get("/pipelines", (_req, res) => res.json({ pipelines: listPipelines() }));
|
|
|
|
router.get("/:id", (req, res) => {
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
res.json({ lane: payload(lane) });
|
|
});
|
|
|
|
router.post("/", (req, res) => {
|
|
try {
|
|
const lane = lanesLib.createLane(req.body || {});
|
|
broadcastLane(lane.id);
|
|
res.status(201).json({ lane: payload(lane) });
|
|
} catch (err) {
|
|
if (err.code === "EBADCWD") {
|
|
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
if (String(err.message).includes("UNIQUE")) {
|
|
return res
|
|
.status(409)
|
|
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
|
|
}
|
|
res.status(500).json({ error: { message: err.message } });
|
|
}
|
|
});
|
|
|
|
router.patch("/:id", (req, res) => {
|
|
if (!lanesLib.getLane(req.params.id)) {
|
|
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
}
|
|
const lane = lanesLib.updateLane(req.params.id, req.body || {});
|
|
broadcastLane(lane.id);
|
|
res.json({ lane: payload(lane) });
|
|
});
|
|
|
|
router.post("/:id/stage", (req, res) => {
|
|
if (!lanesLib.getLane(req.params.id)) {
|
|
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
}
|
|
const lane = lanesLib.setStage(req.params.id, req.body || {});
|
|
broadcastLane(lane.id);
|
|
res.json({ lane: payload(lane) });
|
|
});
|
|
|
|
router.delete("/:id", (req, res) => {
|
|
const ok = lanesLib.deleteLane(req.params.id);
|
|
if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
broadcast("lane_update", { removed: Number(req.params.id) });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
module.exports = router;
|
|
module.exports.broadcastLane = broadcastLane;
|
|
```
|
|
|
|
- [ ] **Step 4: Mount the router**
|
|
|
|
In `server/index.js`, next to the other `require` calls for routers add:
|
|
|
|
```js
|
|
const lanesRouter = require("./routes/lanes");
|
|
```
|
|
|
|
and directly after line 101 (`app.use("/api/run", runRouter);`) add:
|
|
|
|
```js
|
|
app.use("/api/lanes", lanesRouter);
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run: `node --test server/__tests__/lanes-api.test.js`
|
|
Expected: PASS, 8 tests.
|
|
|
|
- [ ] **Step 6: Full server suite + commit**
|
|
|
|
Run: `npm run test:server`
|
|
Expected: all suites pass (no regression in `api.test.js`).
|
|
|
|
```bash
|
|
git add server/routes/lanes.js server/index.js server/__tests__/lanes-api.test.js
|
|
git commit -m "feat(lanes): REST surface and lane_update websocket event"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Bind sessions to lanes from the hook stream
|
|
|
|
**Files:**
|
|
- Modify: `server/routes/hooks.js` (session upsert around `server/routes/hooks.js:126-142`; hook dispatch that already reads `hook_type` near `server/routes/hooks.js:1055`)
|
|
- Test: `server/__tests__/lanes-api.test.js` (append a `describe`)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `resolveLaneByCwd`, `updateLane` from `server/lib/lanes.js`; `broadcastLane` from `server/routes/lanes.js`.
|
|
- Produces: no new exports. Side effects only — `lanes.session_id` is set when a hook arrives from a path under a lane's `cwd`; `lanes.needs_action` is set on a `Notification` hook and cleared by the next non-Notification hook **from the session currently bound to that lane** (evaluated before any rebinding). A hook from a different session rebinds the lane but does not clear a flag it did not raise — two agents sharing one worktree must not cancel each other's "needs you".
|
|
|
|
- [ ] **Step 1: Write the failing test (append to `server/__tests__/lanes-api.test.js`)**
|
|
|
|
```js
|
|
describe("hook → lane binding", () => {
|
|
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
|
|
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-hook-a", title: "Hooked" });
|
|
const id = created.body.lane.id;
|
|
|
|
await request("POST", "/api/hooks", {
|
|
hook_type: "SessionStart",
|
|
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a/sub/dir" },
|
|
});
|
|
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-lane-1");
|
|
|
|
await request("POST", "/api/hooks", {
|
|
hook_type: "Notification",
|
|
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", message: "needs permission" },
|
|
});
|
|
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "needs permission");
|
|
|
|
await request("POST", "/api/hooks", {
|
|
hook_type: "PostToolUse",
|
|
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", tool_name: "Read" },
|
|
});
|
|
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
|
|
|
|
await request("DELETE", `/api/lanes/${id}`);
|
|
});
|
|
|
|
it("ignores a hook whose cwd is under no lane", async () => {
|
|
const before = (await request("GET", "/api/lanes")).body.lanes.length;
|
|
await request("POST", "/api/hooks", {
|
|
hook_type: "SessionStart",
|
|
data: { session_id: "sess-lane-orphan", cwd: "/tmp/not-a-lane" },
|
|
});
|
|
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lanes-api.test.js`
|
|
Expected: FAIL — `session_id` is `null`, no binding happens.
|
|
|
|
- [ ] **Step 3: Add the binding**
|
|
|
|
At the top of `server/routes/hooks.js`, next to the existing requires:
|
|
|
|
```js
|
|
const lanesLib = require("../lib/lanes");
|
|
const { broadcastLane } = require("./lanes");
|
|
```
|
|
|
|
Add this helper below the other module-level helpers in the same file:
|
|
|
|
```js
|
|
/**
|
|
* Attach an incoming hook to the lane that owns its cwd. Lanes are optional and
|
|
* this is best-effort: the hook path must never fail because of lane
|
|
* bookkeeping, so everything here is inside one try/catch.
|
|
*
|
|
* `needs_action` mirrors Claude Code's Notification hook (a permission prompt or
|
|
* an idle nudge). The next non-Notification hook from the same session means the
|
|
* agent is moving again, so the flag clears itself — no user click required.
|
|
*/
|
|
function touchLaneFromHook(hookType, data) {
|
|
try {
|
|
if (!data || !data.cwd) return;
|
|
const lane = lanesLib.resolveLaneByCwd(data.cwd);
|
|
if (!lane) return;
|
|
const patch = {};
|
|
if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id;
|
|
if (hookType === "Notification") {
|
|
patch.needs_action = data.message || "needs you";
|
|
} else if (lane.needs_action) {
|
|
patch.needs_action = null;
|
|
}
|
|
if (!Object.keys(patch).length) return;
|
|
lanesLib.updateLane(lane.id, patch);
|
|
broadcastLane(lane.id);
|
|
} catch {
|
|
/* lane bookkeeping is never allowed to block a hook */
|
|
}
|
|
}
|
|
```
|
|
|
|
In the main hook handler, immediately after `hook_type` and `data` are destructured and the `MISSING_SESSION` guard has passed (near `server/routes/hooks.js:1036`), call:
|
|
|
|
```js
|
|
touchLaneFromHook(hook_type, data);
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `node --test server/__tests__/lanes-api.test.js`
|
|
Expected: PASS, 10 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
Run: `npm run test:server`
|
|
|
|
```bash
|
|
git add server/routes/hooks.js server/__tests__/lanes-api.test.js
|
|
git commit -m "feat(lanes): bind sessions to lanes by cwd and surface needs-you"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: `ccam stage` / `ccam lanes` CLI
|
|
|
|
This is what a skill or a `CLAUDE.md` rule actually calls. A CLI subcommand — not an MCP tool — because `bin/ccam.js` already exists, needs no TypeScript build, and any agent can run it with Bash.
|
|
|
|
**Files:**
|
|
- Modify: `bin/ccam.js` (command switch at `bin/ccam.js:2272-2330`, help text at `bin/ccam.js:1589`)
|
|
- Test: `server/__tests__/lanes-cli.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `GET/POST /api/lanes` from Task 3, through the helpers `bin/ccam.js` already defines at `bin/ccam.js:191-192` — `get(pathname)` and `post(pathname, body)`, both wrapping `api()` (`bin/ccam.js:166`). Do not add a second HTTP client. `api()` already exits 1 with a red `✖ METHOD path → message` line on a non-2xx, so error paths below only handle the cases it does not cover.
|
|
- Produces:
|
|
- `ccam lanes` — table of lanes: id, title, stage, status, liveness, progress.
|
|
- `ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]` — resolves the lane from `--lane`, else `--cwd`, else `process.cwd()`; exits 1 with a clear message when no lane owns that path.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `server/__tests__/lanes-cli.test.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file Tests for the `ccam stage` / `ccam lanes` CLI subcommands: lane
|
|
* resolution from the current directory, the stage round-trip through the HTTP
|
|
* API, and the non-zero exit when the cwd belongs to no lane.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("path");
|
|
const os = require("os");
|
|
const fs = require("fs");
|
|
const http = require("http");
|
|
const { spawn } = require("child_process");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-cli-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
|
|
const LANE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-cli-"));
|
|
const CLI = path.join(__dirname, "..", "..", "bin", "ccam.js");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
function post(urlPath, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlPath, BASE);
|
|
const payload = JSON.stringify(body);
|
|
const req = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname,
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
|
|
},
|
|
(res) => {
|
|
let d = "";
|
|
res.on("data", (c) => (d += c));
|
|
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(d || "{}") }));
|
|
},
|
|
);
|
|
req.on("error", reject);
|
|
req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// MUST be async: the test server runs in THIS process, so a blocking
|
|
// spawnSync would stall the event loop and the CLI child's request to
|
|
// 127.0.0.1 would never be served — a deadlock that looks exactly like a
|
|
// sandbox blocking loopback. Use the async spawn and await the exit.
|
|
function cli(args, cwd) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, [CLI, ...args], {
|
|
cwd,
|
|
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port) },
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (c) => (stdout += c));
|
|
child.stderr.on("data", (c) => (stderr += c));
|
|
child.on("error", reject);
|
|
child.on("close", (status) => resolve({ status, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
before(async () => {
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
await post("/api/lanes", { cwd: LANE_DIR, title: "CLI lane" });
|
|
});
|
|
|
|
after(() => {
|
|
if (server) server.close();
|
|
fs.rmSync(LANE_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("ccam stage", () => {
|
|
it("reports a stage for the lane owning the current directory", () => {
|
|
const r = cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.match(r.stdout, /review/);
|
|
const list = cli(["lanes"], LANE_DIR);
|
|
assert.match(list.stdout, /review/);
|
|
});
|
|
|
|
it("exits non-zero when no lane owns the cwd", () => {
|
|
const r = cli(["stage", "review"], os.tmpdir());
|
|
assert.notEqual(r.status, 0);
|
|
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lanes-cli.test.js`
|
|
Expected: FAIL — `ccam stage` is an unknown command.
|
|
|
|
- [ ] **Step 3: Implement the subcommands**
|
|
|
|
In `bin/ccam.js`, add two command functions alongside `cmdOpen` (`bin/ccam.js:1450`), using the file's existing request helper and output formatter rather than a new one:
|
|
|
|
```js
|
|
/**
|
|
* `ccam lanes` — one row per lane: what it is, where it is in its pipeline, and
|
|
* whether the driving session is still breathing.
|
|
*/
|
|
async function cmdLanes() {
|
|
const { lanes, counts } = await get("/api/lanes");
|
|
if (!lanes.length) {
|
|
console.log("no lanes yet — create one with: ccam lanes add --cwd <path> --title <text>");
|
|
return;
|
|
}
|
|
for (const l of lanes) {
|
|
const needs = l.needs_action ? ` ⚠ ${l.needs_action}` : "";
|
|
console.log(
|
|
`#${l.id} ${(l.title || l.cwd).padEnd(38).slice(0, 38)} ` +
|
|
`${String(l.stage).padEnd(12)} ${String(l.status).padEnd(9)} ` +
|
|
`${String(l.liveness).padEnd(6)} ${String(l.progress).padStart(3)}%${needs}`,
|
|
);
|
|
}
|
|
console.log(
|
|
`\n${counts.total} lanes · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's
|
|
* `state.sh N set stage=…`. A skill calls this at each phase boundary so the
|
|
* dashboard shows a declared stage instead of an inferred one.
|
|
*/
|
|
async function cmdStage(args) {
|
|
const stage = args[0];
|
|
if (!stage || stage.startsWith("--")) {
|
|
console.error("usage: ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const flag = (name) => {
|
|
const i = args.indexOf(`--${name}`);
|
|
return i > -1 ? args[i + 1] : undefined;
|
|
};
|
|
|
|
let laneId = flag("lane");
|
|
if (!laneId) {
|
|
const cwd = require("path").resolve(flag("cwd") || process.cwd());
|
|
const { lanes } = await get("/api/lanes");
|
|
const match = lanes
|
|
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
|
|
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
|
|
if (!match) {
|
|
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
laneId = match.id;
|
|
}
|
|
|
|
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
|
stage,
|
|
status: flag("status"),
|
|
evidence: flag("evidence"),
|
|
note: flag("note"),
|
|
result: flag("result"),
|
|
});
|
|
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
|
}
|
|
```
|
|
|
|
Wire them into the command switch (`bin/ccam.js:2272`), matching the surrounding `case` style:
|
|
|
|
```js
|
|
case "lanes":
|
|
return cmdLanes(rest);
|
|
case "stage":
|
|
return cmdStage(rest);
|
|
```
|
|
|
|
Add both to `cmdHelp()` (`bin/ccam.js:1589`) in the existing help layout:
|
|
|
|
```
|
|
lanes List lanes with stage, liveness and progress
|
|
stage <stage> [flags] Report the current pipeline stage for a lane
|
|
```
|
|
|
|
`get` and `post` are the module-level helpers already defined at `bin/ccam.js:191-192`; both are in scope for any command function in that file.
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `node --test server/__tests__/lanes-cli.test.js`
|
|
Expected: PASS, 2 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add bin/ccam.js server/__tests__/lanes-cli.test.js
|
|
git commit -m "feat(lanes): ccam stage and ccam lanes subcommands"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Action layer — start / stop / resume / message / clear / remove
|
|
|
|
**Files:**
|
|
- Modify: `server/routes/run.js` (extract the same-origin guard so it can be shared — export only, no behavior change)
|
|
- Modify: `server/routes/lanes.js` (add the action route)
|
|
- Test: `server/__tests__/lanes-api.test.js` (append a `describe`)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `spawnRun`, `sendInput`, `killRun`, `getRun` from `server/lib/run-spawner.js`; `sameOriginGuard` from `server/routes/run.js`.
|
|
- Produces: `POST /api/lanes/:id/:action` where `action ∈ start | stop | message | clear | remove`.
|
|
- `start` body `{prompt, model?, permissionMode?, effort?, resumeSessionId?}` → spawns in `lane.cwd`, stores `run_id`, sets `status: "running"`. `resumeSessionId` (or `resume: true`, meaning the lane's own `session_id`) is how "resume" is expressed — no separate action.
|
|
- `stop` → `killRun(lane.run_id)`, sets `status: "idle"`.
|
|
- `message` body `{text}` → `sendInput(lane.run_id, text)`; also clears `needs_action`.
|
|
- `clear` → `clearLane(id)`.
|
|
- `remove` body `{confirm: true}` → 400 without the flag, else deletes.
|
|
|
|
- [ ] **Step 1: Write the failing test (append to `server/__tests__/lanes-api.test.js`)**
|
|
|
|
```js
|
|
describe("lane actions", () => {
|
|
it("refuses remove without an explicit confirm flag", async () => {
|
|
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-a" });
|
|
const id = c.body.lane.id;
|
|
assert.equal((await request("POST", `/api/lanes/${id}/remove`, {})).status, 400);
|
|
assert.equal((await request("POST", `/api/lanes/${id}/remove`, { confirm: true })).status, 200);
|
|
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 404);
|
|
});
|
|
|
|
it("rejects an unknown action", async () => {
|
|
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-b" });
|
|
const r = await request("POST", `/api/lanes/${c.body.lane.id}/frobnicate`, {});
|
|
assert.equal(r.status, 400);
|
|
await request("POST", `/api/lanes/${c.body.lane.id}/remove`, { confirm: true });
|
|
});
|
|
|
|
it("clear resets stage state but keeps the lane", async () => {
|
|
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-c", title: "Keep me" });
|
|
const id = c.body.lane.id;
|
|
await request("POST", `/api/lanes/${id}/stage`, { stage: "review", status: "running" });
|
|
const r = await request("POST", `/api/lanes/${id}/clear`, {});
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.lane.stage, "idle");
|
|
assert.equal(r.body.lane.title, "Keep me");
|
|
assert.deepEqual(r.body.lane.stages, {});
|
|
await request("POST", `/api/lanes/${id}/remove`, { confirm: true });
|
|
});
|
|
|
|
it("stop on a lane with no run is a no-op, not a 500", async () => {
|
|
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-d" });
|
|
const r = await request("POST", `/api/lanes/${c.body.lane.id}/stop`, {});
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.lane.status, "idle");
|
|
await request("POST", `/api/lanes/${c.body.lane.id}/remove`, { confirm: true });
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lanes-api.test.js`
|
|
Expected: FAIL — the action routes 404.
|
|
|
|
- [ ] **Step 3: Export the guard from `server/routes/run.js`**
|
|
|
|
At the bottom of `server/routes/run.js`, beside the existing `module.exports = router;`, add:
|
|
|
|
```js
|
|
// Shared with routes/lanes.js: lane actions spawn processes through the same
|
|
// run-spawner, so they must sit behind the same loopback/same-origin check.
|
|
module.exports.sameOriginGuard = sameOriginGuard;
|
|
```
|
|
|
|
- [ ] **Step 4: Add the action route to `server/routes/lanes.js`**
|
|
|
|
Add the requires at the top of the file:
|
|
|
|
```js
|
|
const runs = require("../lib/run-spawner");
|
|
const { sameOriginGuard } = require("./run");
|
|
```
|
|
|
|
Add the route AFTER `router.post("/:id/stage", …)` and BEFORE `module.exports`:
|
|
|
|
```js
|
|
const ACTIONS = new Set(["start", "stop", "message", "clear", "remove"]);
|
|
|
|
/**
|
|
* Lane control. Deliberately thin: every action maps onto one existing
|
|
* run-spawner call. There is no queue, no chaining, no gate evaluation — the
|
|
* dashboard drives a lane, it does not orchestrate a pipeline.
|
|
*/
|
|
router.post("/:id/:action", sameOriginGuard, (req, res) => {
|
|
const { action } = req.params;
|
|
if (!ACTIONS.has(action)) {
|
|
return res.status(400).json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
|
|
}
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
const body = req.body || {};
|
|
|
|
try {
|
|
switch (action) {
|
|
case "start": {
|
|
const handle = runs.spawnRun({
|
|
mode: "conversation",
|
|
prompt: body.prompt || "",
|
|
cwd: lane.cwd,
|
|
model: body.model,
|
|
permissionMode: body.permissionMode,
|
|
effort: body.effort,
|
|
resumeSessionId: body.resumeSessionId || (body.resume ? lane.session_id : undefined),
|
|
});
|
|
lanesLib.updateLane(lane.id, { run_id: handle.id, status: "running" });
|
|
break;
|
|
}
|
|
case "stop": {
|
|
// A lane with no live run is already stopped — say so, don't 500.
|
|
if (lane.run_id) {
|
|
try { runs.killRun(lane.run_id); } catch { /* already gone */ }
|
|
}
|
|
lanesLib.updateLane(lane.id, { status: "idle", run_id: null });
|
|
break;
|
|
}
|
|
case "message": {
|
|
if (!lane.run_id) {
|
|
return res.status(409).json({ error: { code: "ENORUN", message: "lane has no live run" } });
|
|
}
|
|
runs.sendInput(lane.run_id, String(body.text || ""));
|
|
lanesLib.updateLane(lane.id, { needs_action: null });
|
|
break;
|
|
}
|
|
case "clear":
|
|
lanesLib.clearLane(lane.id);
|
|
break;
|
|
case "remove": {
|
|
if (body.confirm !== true) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "ECONFIRM", message: "remove requires confirm: true" } });
|
|
}
|
|
lanesLib.deleteLane(lane.id);
|
|
broadcast("lane_update", { removed: lane.id });
|
|
return res.json({ ok: true });
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
|
|
broadcastLane(lane.id);
|
|
res.json({ lane: payload(lanesLib.getLane(lane.id)) });
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run: `node --test server/__tests__/lanes-api.test.js`
|
|
Expected: PASS, 14 tests.
|
|
|
|
- [ ] **Step 6: Full server suite + commit**
|
|
|
|
Run: `npm run test:server`
|
|
|
|
```bash
|
|
git add server/routes/lanes.js server/routes/run.js server/__tests__/lanes-api.test.js
|
|
git commit -m "feat(lanes): per-lane start/stop/message/clear/remove actions"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Lanes page — pipeline map, cards, live updates
|
|
|
|
**Files:**
|
|
- Create: `client/src/components/lanes/PipelineMap.tsx`
|
|
- Create: `client/src/components/lanes/LaneCard.tsx`
|
|
- Create: `client/src/pages/Lanes.tsx`
|
|
- Create: `client/src/components/lanes/__tests__/PipelineMap.test.tsx`
|
|
- Modify: `client/src/lib/api.ts` (add `Lane`, `LaneNode`, `LaneCounts` types + the `api.lanes` group, following the existing group style)
|
|
- Modify: `client/src/lib/types.ts:1648` (add `"lane_update"` to the `WSMessage["type"]` union and its payload to the `data` union)
|
|
- Modify: `client/src/App.tsx` (route)
|
|
- Modify: `client/src/components/Sidebar.tsx` (nav entry)
|
|
- Modify: `client/src/i18n/*.json` (strings)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `GET /api/lanes`, `POST /api/lanes/:id/:action`, WS `lane_update` from Tasks 3 and 6; `eventBus` (`client/src/lib/eventBus.ts`) as used by the other pages.
|
|
- Produces:
|
|
- `export interface LaneNode { id: string; label: string; icon: string; gate: boolean; state: "done" | "current" | "passed-no-evidence" | "failed" | "pending" }`
|
|
- `export interface Lane { id: number; title: string; cwd: string; branch: string | null; pipeline: string; session_id: string | null; run_id: string | null; stage: string; stage_since: string | null; status: string; gate_decision: string | null; ci_status: string | null; needs_action: string | null; links: Record<string, string>; stages: Record<string, { enteredAt: string; evidence: string | null; result: string | null }>; notes: string | null; pipeline_name: string; pipeline_nodes: LaneNode[]; progress: number; stage_seconds: number | null; last_event_seconds: number | null; liveness: "active" | "idle" | "dead" }`
|
|
- `export interface LaneCounts { total: number; running: number; needs_you: number; dead: number }`
|
|
- `api.lanes.list(): Promise<{lanes: Lane[]; counts: LaneCounts}>`, `api.lanes.get(id)`, `api.lanes.create(body)`, `api.lanes.update(id, patch)`, `api.lanes.stage(id, body)`, `api.lanes.action(id, action, body?)`
|
|
- `<PipelineMap nodes={LaneNode[]} />`, `<LaneCard lane={Lane} onAction={(action, body?) => void} />`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `client/src/components/lanes/__tests__/PipelineMap.test.tsx`:
|
|
|
|
```tsx
|
|
/**
|
|
* @file Rendering tests for the lane pipeline map: every node renders with a
|
|
* state-specific class so "done", "current" and "passed without evidence" stay
|
|
* visually distinguishable, and the amber state is never conflated with done.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { render, screen } from "@testing-library/react";
|
|
import PipelineMap from "../PipelineMap";
|
|
import type { LaneNode } from "../../../lib/api";
|
|
|
|
const nodes: LaneNode[] = [
|
|
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
|
{ id: "implement", label: "implement", icon: "🛠", gate: false, state: "passed-no-evidence" },
|
|
{ id: "review", label: "review", icon: "👀", gate: true, state: "current" },
|
|
{ id: "gate", label: "gate", icon: "🚦", gate: true, state: "failed" },
|
|
{ id: "done", label: "done", icon: "✅", gate: false, state: "pending" },
|
|
];
|
|
|
|
describe("PipelineMap", () => {
|
|
it("renders one element per node, labelled by state", () => {
|
|
render(<PipelineMap nodes={nodes} />);
|
|
expect(screen.getAllByTestId(/^pipeline-node-/)).toHaveLength(5);
|
|
expect(screen.getByTestId("pipeline-node-plan")).toHaveAttribute("data-state", "done");
|
|
expect(screen.getByTestId("pipeline-node-implement")).toHaveAttribute(
|
|
"data-state",
|
|
"passed-no-evidence",
|
|
);
|
|
expect(screen.getByTestId("pipeline-node-review")).toHaveAttribute("data-state", "current");
|
|
expect(screen.getByTestId("pipeline-node-gate")).toHaveAttribute("data-state", "failed");
|
|
expect(screen.getByTestId("pipeline-node-done")).toHaveAttribute("data-state", "pending");
|
|
});
|
|
|
|
it("gives amber nodes a different class from done nodes", () => {
|
|
render(<PipelineMap nodes={nodes} />);
|
|
const done = screen.getByTestId("pipeline-node-plan").className;
|
|
const amber = screen.getByTestId("pipeline-node-implement").className;
|
|
expect(done).not.toEqual(amber);
|
|
});
|
|
|
|
it("renders nothing but an empty hint when there are no nodes", () => {
|
|
render(<PipelineMap nodes={[]} />);
|
|
expect(screen.queryAllByTestId(/^pipeline-node-/)).toHaveLength(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd client && npx vitest run src/components/lanes/__tests__/PipelineMap.test.tsx`
|
|
Expected: FAIL — cannot resolve `../PipelineMap`.
|
|
|
|
- [ ] **Step 3: Write `PipelineMap.tsx`**
|
|
|
|
Create `client/src/components/lanes/PipelineMap.tsx`:
|
|
|
|
```tsx
|
|
/**
|
|
* @file The lane pipeline map: a horizontal chain of stage nodes coloured by
|
|
* state. Layout is computed from the node list (flex + connectors), never from
|
|
* hardcoded coordinates, so a lane can use a longer or shorter template without
|
|
* touching this component. "passed without evidence" is deliberately its own
|
|
* colour: a stage the agent claimed but left no artifact for is not the same as
|
|
* a stage that is genuinely done.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import type { LaneNode } from "../../lib/api";
|
|
|
|
const STATE_CLASS: Record<LaneNode["state"], string> = {
|
|
done: "border-emerald-500 text-emerald-400 bg-emerald-500/10",
|
|
current: "border-blue-400 text-blue-300 bg-blue-500/20 ring-2 ring-blue-400/40",
|
|
"passed-no-evidence": "border-amber-500 text-amber-400 bg-amber-500/10",
|
|
failed: "border-red-500 text-red-400 bg-red-500/10",
|
|
pending: "border-neutral-700 text-neutral-500 bg-transparent",
|
|
};
|
|
|
|
export default function PipelineMap({ nodes }: { nodes: LaneNode[] }) {
|
|
if (!nodes.length) return <div className="text-xs text-neutral-500">no pipeline</div>;
|
|
return (
|
|
<div className="flex items-center gap-1 overflow-x-auto py-2">
|
|
{nodes.map((n, i) => (
|
|
<div key={n.id} className="flex items-center gap-1">
|
|
<div
|
|
data-testid={`pipeline-node-${n.id}`}
|
|
data-state={n.state}
|
|
title={`${n.label} — ${n.state}`}
|
|
className={`flex h-14 w-14 shrink-0 flex-col items-center justify-center rounded-full border text-[10px] ${STATE_CLASS[n.state]}`}
|
|
>
|
|
<span className="text-base leading-none">{n.icon}</span>
|
|
<span className="mt-0.5 max-w-[52px] truncate px-1">{n.label}</span>
|
|
</div>
|
|
{i < nodes.length - 1 && <div className="h-px w-4 shrink-0 bg-neutral-700" />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `cd client && npx vitest run src/components/lanes/__tests__/PipelineMap.test.tsx`
|
|
Expected: PASS, 3 tests.
|
|
|
|
- [ ] **Step 5: Add the API client group**
|
|
|
|
In `client/src/lib/api.ts`, add the three interfaces from the **Interfaces** block above next to the other exported types, then add this group to the `api` object, matching the surrounding group style (same `request` helper, same scoped-GET convention):
|
|
|
|
```ts
|
|
lanes: {
|
|
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
|
|
get: (id: number) => request<{ lane: Lane }>(`/lanes/${id}`),
|
|
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
|
|
request<{ lane: Lane }>("/lanes", { method: "POST", body: JSON.stringify(body) }),
|
|
update: (id: number, patch: Partial<Lane>) =>
|
|
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
|
stage: (id: number, body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }) =>
|
|
request<{ lane: Lane }>(`/lanes/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
|
|
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
|
|
request<{ lane?: Lane; ok?: boolean }>(`/lanes/${id}/${action}`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 6: Write `LaneCard.tsx`**
|
|
|
|
Create `client/src/components/lanes/LaneCard.tsx`:
|
|
|
|
```tsx
|
|
/**
|
|
* @file One lane's card: title, stage badge with time-on-phase, progress bar,
|
|
* branch/CI/PR facts, the "needs you" banner sourced from Claude Code's
|
|
* Notification hook, and the control row. A dead lane (its driving session went
|
|
* silent while it should have been working) is called out loudly — that is the
|
|
* failure this view exists to catch.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import type { Lane } from "../../lib/api";
|
|
|
|
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
|
active: "bg-emerald-400",
|
|
idle: "bg-neutral-500",
|
|
dead: "bg-red-500",
|
|
};
|
|
|
|
function since(sec: number | null): string {
|
|
if (sec === null) return "—";
|
|
if (sec < 60) return `${sec}s`;
|
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
|
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
|
|
}
|
|
|
|
export default function LaneCard({
|
|
lane,
|
|
onAction,
|
|
}: {
|
|
lane: Lane;
|
|
onAction: (action: string, body?: Record<string, unknown>) => void;
|
|
}) {
|
|
return (
|
|
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="text-xs uppercase tracking-wide text-neutral-500">Lane {lane.id}</span>
|
|
<span className="flex items-center gap-1.5 text-xs">
|
|
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
|
{lane.liveness === "dead" ? "DEAD" : lane.status.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
|
|
<h3 className="mb-2 text-sm font-semibold text-neutral-100">{lane.title || lane.cwd}</h3>
|
|
|
|
<div className="mb-2 flex items-center gap-2 text-xs">
|
|
<span className="rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">{lane.stage}</span>
|
|
<div className="h-1.5 flex-1 rounded bg-neutral-800">
|
|
<div className="h-1.5 rounded bg-blue-500" style={{ width: `${lane.progress}%` }} />
|
|
</div>
|
|
<span className="text-neutral-500">{lane.progress}%</span>
|
|
<span className="text-neutral-500">{since(lane.stage_seconds)}</span>
|
|
</div>
|
|
|
|
{lane.needs_action && (
|
|
<div className="mb-2 rounded border border-amber-600/50 bg-amber-500/10 px-2 py-1 text-xs text-amber-300">
|
|
⚠ {lane.needs_action}
|
|
</div>
|
|
)}
|
|
|
|
<dl className="mb-3 space-y-0.5 text-xs text-neutral-400">
|
|
{lane.branch && <div>⑂ {lane.branch}</div>}
|
|
{lane.ci_status && <div>CI {lane.ci_status}</div>}
|
|
<div className="truncate" title={lane.cwd}>
|
|
{lane.cwd}
|
|
</div>
|
|
</dl>
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{(["start", "stop", "clear"] as const).map((a) => (
|
|
<button
|
|
key={a}
|
|
type="button"
|
|
onClick={() => onAction(a)}
|
|
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
|
|
>
|
|
{a}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => onAction("remove", { confirm: true })}
|
|
className="rounded border border-red-800 px-2 py-1 text-xs text-red-400 hover:bg-red-950"
|
|
>
|
|
remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Write `Lanes.tsx`**
|
|
|
|
Create `client/src/pages/Lanes.tsx`:
|
|
|
|
```tsx
|
|
/**
|
|
* @file The Lanes page: header counters, the selected lane's pipeline map, and
|
|
* a card grid of every lane. Data arrives two ways — one `GET /api/lanes` on
|
|
* mount, then incremental `lane_update` messages off the shared event bus — so
|
|
* the page stays live without polling.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { api, type Lane, type LaneCounts } from "../lib/api";
|
|
import { eventBus } from "../lib/eventBus";
|
|
import PipelineMap from "../components/lanes/PipelineMap";
|
|
import LaneCard from "../components/lanes/LaneCard";
|
|
|
|
export default function Lanes() {
|
|
const [lanes, setLanes] = useState<Lane[]>([]);
|
|
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
|
|
const [selected, setSelected] = useState<number | null>(null);
|
|
|
|
const refresh = useCallback(async () => {
|
|
const r = await api.lanes.list();
|
|
setLanes(r.lanes);
|
|
setCounts(r.counts);
|
|
setSelected((cur) => (cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
// Incremental: a lane_update carries the whole lane, so patch in place and
|
|
// only refetch when a lane disappears (counters must stay truthful).
|
|
return eventBus.subscribe((msg: WSMessage) => {
|
|
if (msg.type !== "lane_update") return;
|
|
const payload = msg.data as { lane?: Lane; removed?: number };
|
|
if (payload.removed !== undefined) return void refresh();
|
|
const lane = payload.lane;
|
|
if (!lane) return;
|
|
setLanes((cur) => {
|
|
const i = cur.findIndex((l) => l.id === lane.id);
|
|
if (i === -1) return [...cur, lane];
|
|
const next = [...cur];
|
|
next[i] = lane;
|
|
return next;
|
|
});
|
|
});
|
|
}, [refresh]);
|
|
|
|
const act = async (id: number, action: string, body?: Record<string, unknown>) => {
|
|
await api.lanes.action(id, action, body);
|
|
if (action === "remove") await refresh();
|
|
};
|
|
|
|
const current = lanes.find((l) => l.id === selected) || null;
|
|
|
|
return (
|
|
<div className="space-y-4 p-4">
|
|
<header className="flex items-center gap-3">
|
|
<h1 className="text-lg font-semibold text-neutral-100">Lanes</h1>
|
|
<span className="rounded bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
|
|
{counts.total} lanes
|
|
</span>
|
|
<span className="rounded bg-blue-500/20 px-2 py-0.5 text-xs text-blue-300">
|
|
{counts.running} running
|
|
</span>
|
|
{counts.needs_you > 0 && (
|
|
<span className="rounded bg-amber-500/20 px-2 py-0.5 text-xs text-amber-300">
|
|
{counts.needs_you} need you
|
|
</span>
|
|
)}
|
|
{counts.dead > 0 && (
|
|
<span className="rounded bg-red-500/20 px-2 py-0.5 text-xs text-red-300">
|
|
{counts.dead} dead
|
|
</span>
|
|
)}
|
|
</header>
|
|
|
|
{current && (
|
|
<section className="rounded-lg border border-neutral-800 bg-neutral-900/40 p-4">
|
|
<div className="mb-1 text-sm text-neutral-300">
|
|
Lane {current.id} · {current.title || current.cwd} · {current.pipeline_name}
|
|
</div>
|
|
<PipelineMap nodes={current.pipeline_nodes} />
|
|
</section>
|
|
)}
|
|
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{lanes.map((l) => (
|
|
<button key={l.id} type="button" className="text-left" onClick={() => setSelected(l.id)}>
|
|
<LaneCard lane={l} onAction={(a, b) => act(l.id, a, b)} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{!lanes.length && (
|
|
<p className="text-sm text-neutral-500">
|
|
No lanes yet. Create one from a working directory: <code>ccam lanes add --cwd $(pwd)</code>
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
`eventBus.subscribe(handler)` (`client/src/lib/eventBus.ts:109`) receives EVERY inbound `WSMessage` and returns its own unsubscribe function — hence the `msg.type !== "lane_update"` filter and returning the disposer straight out of the effect, matching `client/src/pages/Dashboard.tsx:1082`. Import both `eventBus` and the `WSMessage` type (`client/src/lib/types.ts:1648`).
|
|
|
|
Because `WSMessage["type"]` is a closed union, add `"lane_update"` to it in `client/src/lib/types.ts` and extend the `data` union with `{ lane?: Lane; removed?: number }` — otherwise the filter above does not typecheck.
|
|
|
|
- [ ] **Step 8: Route and nav**
|
|
|
|
In `client/src/App.tsx`, add the import beside the other page imports and the route beside the others inside `Layout`:
|
|
|
|
```tsx
|
|
import Lanes from "./pages/Lanes";
|
|
```
|
|
```tsx
|
|
<Route path="lanes" element={<Lanes />} />
|
|
```
|
|
|
|
In `client/src/components/Sidebar.tsx`, add a nav item pointing at `/lanes` with the label pulled from i18n, matching the existing item structure. Add the string key `nav.lanes` (value `"Lanes"`) to every locale file under `client/src/i18n/`.
|
|
|
|
- [ ] **Step 9: Run the client suite**
|
|
|
|
Run: `npm run test:client`
|
|
Expected: PASS. The per-screen snapshot suite (`client/src/pages/__tests__/screens.snapshot.test.tsx`) may fail solely because a new nav entry exists — review the diff, confirm it is only the added Lanes item, then regenerate with `cd client && npx vitest run -u`.
|
|
|
|
- [ ] **Step 10: Header audit, build, commit**
|
|
|
|
Run: `bash .claude/skills/file-headers/scripts/check-headers.sh`
|
|
Run: `npm run build`
|
|
Expected: both clean.
|
|
|
|
```bash
|
|
git add client/src/components/lanes client/src/pages/Lanes.tsx client/src/lib/api.ts client/src/App.tsx client/src/components/Sidebar.tsx client/src/i18n
|
|
git commit -m "feat(lanes): lanes page with pipeline map and live updates"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Docs
|
|
|
|
The repo's own rules require docs to move with behavior. This is one task, not a step folded into each of the above, because it is a single coherent write-up.
|
|
|
|
**Files:**
|
|
- Create: `docs/LANES.md`
|
|
- Modify: `CLAUDE.md` (a `## Lanes` section: what a lane is, that CCAM does not orchestrate, and that stage reporting is the agent's job via `ccam stage`)
|
|
- Modify: `README.md` (one paragraph + the two new CLI commands in the command list)
|
|
|
|
**Interfaces:**
|
|
- Consumes: the behavior shipped in Tasks 1-7.
|
|
- Produces: no code.
|
|
|
|
- [ ] **Step 1: Write `docs/LANES.md`**
|
|
|
|
It must cover, with exact paths and runnable commands: what a lane is and why it is keyed by `cwd` rather than `session_id`; the five node states and specifically why `passed-no-evidence` is amber rather than green; how a skill reports a stage (`ccam stage review --evidence "3 findings"`); how to add a custom pipeline template (`DASHBOARD_PIPELINES_DIR`, the JSON shape from `server/data/pipelines/default.json`); the liveness rule (`LANE_DEAD_SEC`, why a silent watcher is dead but a silent idle lane is not); and an explicit statement that CCAM does not chain, queue, retry, or gate — the driving Claude session does.
|
|
|
|
- [ ] **Step 2: Update `CLAUDE.md` and `README.md`**
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add docs/LANES.md CLAUDE.md README.md
|
|
git commit -m "docs(lanes): document lanes, pipeline templates and stage reporting"
|
|
```
|
|
|
|
---
|
|
|
|
## Out of scope (deliberately)
|
|
|
|
Named here so a later reader knows these were decided, not forgotten:
|
|
|
|
- **Worktree / port / database provisioning per lane.** Shipyard's `bin/lane-up.sh` territory. A lane here is just a directory that already exists.
|
|
- **Any orchestrator** — no queue, no step chaining, no retry, no gate evaluation. Approach A: adding one would create a second scheduler competing with the Claude session that is already driving.
|
|
- **An MCP `report_stage` tool.** `ccam stage` covers it with no TypeScript build step. Add the MCP tool later only if a session turns out to reach for tools more reliably than Bash.
|
|
- **Stage inference from `TodoWrite` / `workflows.phases` for un-instrumented sessions.** Worth doing, but it is a distinct feature (inferred vs declared stage, with the UI distinguishing them) and belongs in its own plan once declared stages are proven.
|