feat(lanes): make the pipeline map track a skill's real progress
A lane's pipeline map only ever moved when a skill remembered to call `ccam stage`, and the ship-feature template shipped with no detection rules at all — so a lane driven by Superpowers skills sat at whatever stage it last declared, and the `gates` node was never declared by anything. Detection (`detect` rules on each node) now covers the Superpowers skill invocations and the `ccam`/`gh` commands the ship-feature-lane skill actually runs. It stays a safety net, not the mechanism: forward-only, never `done`, never overriding a declaration. Two rules were deliberately left out — `git diff` on `review` (this repo's own tests record it pinning a lane at `review` on a real session) and anything on `merged`/`done`. Stage vocabulary grows to 50 names over the same 16 nodes, following Shipyard's PHASES shape: sub-states like `migration-collision`, `e2e-scoped` and `gate-blocked` say WHY a lane sits on a node without the map growing a node per reason. Every alias has a source — the skill declares it, `default.json` uses it, or Shipyard's PHASES lists it. Two silent failures fixed along the way: - `lane.stages` is keyed by the raw declared string, so a stage declared under an alias lost its `--evidence` and rendered amber instead of green. `stageRecords` resolves each key onto its node. - `ccam stage <typo>` stored fine and then rendered nowhere. It now warns on stderr while still exiting 0. `ccam lanes pipeline` closes the gap that made all of this invisible: a lane could only be assigned a template at creation, and no screen in the web UI offers the choice, so every lane added from "+ Add lane" was stuck on `default`'s 8 nodes. An unknown template id is now refused rather than silently falling back to `default` on read. Also merges the repo's own `ship-feature` skill into the Superpowers workflow: it delegates planning/TDD/review/verification instead of restating them, and declares a stage at each phase.
This commit is contained in:
@@ -186,6 +186,31 @@ describe("ccam stage", () => {
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
|
||||
});
|
||||
|
||||
it("warns, but still records, a stage name matching no pipeline node", async () => {
|
||||
// setStage stores the string verbatim, so a typo'd stage is accepted and
|
||||
// then renders nowhere (phaseIdx -1, progress 0). Silent acceptance is how
|
||||
// a lane ends up looking unstarted for an entire pipeline run.
|
||||
const r = await cli(["stage", "revieww"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /revieww/);
|
||||
assert.match(r.stderr, /matches no node/i);
|
||||
assert.match(r.stderr, /revieww/);
|
||||
});
|
||||
|
||||
it("does not warn for a stage declared by alias", async () => {
|
||||
const r = await cli(["stage", "planning"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /matches no node/i);
|
||||
});
|
||||
|
||||
it("does not warn for a valid stage reported as failed", async () => {
|
||||
// --result fail paints the node `failed`, not `current`; the warning must
|
||||
// not read that as an unknown stage.
|
||||
const r = await cli(["stage", "review", "--result", "fail"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /matches no node/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ccam lanes add", () => {
|
||||
@@ -248,6 +273,35 @@ describe("ccam lanes add", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
|
||||
// `--pipeline` was creation-only, so every lane added from the dashboard
|
||||
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
|
||||
const show = await cli(["lanes", "pipeline"], LANE_DIR);
|
||||
assert.equal(show.status, 0, show.stderr);
|
||||
assert.match(show.stdout, /default/);
|
||||
assert.match(show.stdout, /available:.*ship-feature/);
|
||||
|
||||
const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
|
||||
assert.equal(set.status, 0, set.stderr);
|
||||
assert.match(set.stdout, /ship-feature/);
|
||||
assert.match(set.stdout, /16 nodes/);
|
||||
|
||||
const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
|
||||
assert.equal(back.status, 0, back.stderr);
|
||||
assert.match(back.stdout, /8 nodes/);
|
||||
});
|
||||
|
||||
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
|
||||
// getPipeline() returns the default template for an unknown id, so without
|
||||
// a write-side check a typo would store, render `default`, and look fine.
|
||||
const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
|
||||
|
||||
const after = await cli(["lanes", "pipeline"], LANE_DIR);
|
||||
assert.match(after.stdout, /default/);
|
||||
});
|
||||
|
||||
it("provisions a managed worktree lane and reports it ready", async () => {
|
||||
const r = await cli(
|
||||
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
|
||||
|
||||
@@ -77,6 +77,44 @@ describe("pipelines", () => {
|
||||
assert.equal(byId.plan, "passed-no-evidence");
|
||||
});
|
||||
|
||||
it("keeps the evidence of a stage declared under an alias", () => {
|
||||
// `lane.stages` is keyed by the raw declared string, so `ccam stage
|
||||
// planning --evidence x` files the record under "planning" while the node
|
||||
// is "plan". Looking it up by node id alone loses the evidence and paints
|
||||
// a `done` node amber — silently, and only for agents that use an alias.
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
const lane = {
|
||||
stage: "review",
|
||||
stages: { planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" } },
|
||||
};
|
||||
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
||||
assert.equal(byId.plan, "done");
|
||||
});
|
||||
|
||||
it("prefers a node-id record over an alias record for the same node", () => {
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
const lane = {
|
||||
stage: "review",
|
||||
stages: {
|
||||
planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: null },
|
||||
plan: { enteredAt: "2026-07-27T00:30:00Z", evidence: "docs/plan.md" },
|
||||
},
|
||||
};
|
||||
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
||||
assert.equal(byId.plan, "done");
|
||||
});
|
||||
|
||||
it("ignores a recorded stage matching no node instead of shifting the others", () => {
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
const lane = {
|
||||
stage: "review",
|
||||
stages: { "totally-unknown": { enteredAt: "x", evidence: "y" } },
|
||||
};
|
||||
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
||||
assert.equal(byId.plan, "passed-no-evidence");
|
||||
assert.equal(byId.review, "current");
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -482,4 +482,138 @@ test.describe("ship-feature pipeline template", () => {
|
||||
const ids = pipeline.nodes.map((n) => n.id);
|
||||
assert.deepEqual(ids, [...new Set(ids)]);
|
||||
});
|
||||
|
||||
test.it("no alias collides with another node's id or with a second node's alias", () => {
|
||||
// phaseIdx() takes the FIRST node whose id or alias matches, so a duplicate
|
||||
// silently resolves a declaration onto the wrong node — the failure mode
|
||||
// aliases are supposed to prevent.
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const seen = new Map();
|
||||
for (const n of pipeline.nodes) {
|
||||
for (const name of [n.id, ...n.aliases]) {
|
||||
const key = name.toLowerCase();
|
||||
assert.equal(
|
||||
seen.has(key),
|
||||
false,
|
||||
`"${name}" claimed by both ${seen.get(key)} and ${n.id}`
|
||||
);
|
||||
seen.set(key, n.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Same discipline as default.json's end-to-end rule sweep: a rule pinned only
|
||||
// against a fixture can ship inert, and these fire on a live lane's hooks.
|
||||
test.it("every shipped ship-feature.json rule fires end-to-end via getPipeline", () => {
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const cases = [
|
||||
["intake", "Skill", { skill: "superpowers:brainstorming" }],
|
||||
["intake", "Bash", { command: "ccam feature activate lane-stage-detect" }],
|
||||
["plan", "Skill", { skill: "superpowers:writing-plans" }],
|
||||
["plan", "Write", { file_path: "docs/superpowers/specs/lane-foo.md" }],
|
||||
["implementing", "Skill", { skill: "superpowers:test-driven-development" }],
|
||||
["implementing", "Skill", { skill: "superpowers:systematic-debugging" }],
|
||||
["implementing", "Edit", { file_path: "server/lib/app.js" }],
|
||||
["implementing", "Write", { file_path: "server/lib/app.js" }],
|
||||
["gates", "Bash", { command: "ccam lanes hook ci-gate" }],
|
||||
["gates", "Bash", { command: "ccam lanes sync-base --check feat/foo" }],
|
||||
["e2e-feature", "Bash", { command: "ccam lanes up --qc" }],
|
||||
["e2e-feature", "Bash", { command: "ccam lanes hook e2e" }],
|
||||
["review", "Skill", { skill: "code-review" }],
|
||||
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
|
||||
["qc", "Agent", { subagent_type: "qc-local" }],
|
||||
["qc", "Bash", { command: "ccam lanes proof-link" }],
|
||||
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
|
||||
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
|
||||
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
|
||||
["publishing", "Bash", { command: "git push -u origin feat/foo" }],
|
||||
["pr-open", "Bash", { command: "gh pr create --base development --fill" }],
|
||||
["watching-pr", "Bash", { command: "gh pr view https://x/pull/1 --json state" }],
|
||||
];
|
||||
for (const [nodeId, tool_name, tool_input] of cases) {
|
||||
const got = detect(pipeline, { tool_name, tool_input });
|
||||
assert.equal(got && got.nodeId, nodeId, `${tool_name} ${JSON.stringify(tool_input)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("every sub-state the skill declares resolves onto its node", () => {
|
||||
// These names exist so a skill can say WHY a lane sits on a node without
|
||||
// the map growing a node per reason (Shipyard's PHASES shape: ~35 stage
|
||||
// names over 13 nodes). An unresolvable one is worse than no alias: it
|
||||
// records, renders nowhere, and only warns.
|
||||
const { reload, phaseIdx } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const subStates = {
|
||||
bootstrapping: "intake",
|
||||
"migration-collision": "gates",
|
||||
"sync-conflict": "gates",
|
||||
booting: "e2e-feature",
|
||||
"e2e-scoped": "e2e-feature",
|
||||
live: "e2e-feature",
|
||||
"gate-blocked": "gate",
|
||||
"push-conflict": "pr-open",
|
||||
"push-revalidate": "pr-open",
|
||||
"pr-comment-fix": "watching-pr",
|
||||
};
|
||||
for (const [declared, nodeId] of Object.entries(subStates)) {
|
||||
assert.equal(
|
||||
phaseIdx(pipeline, declared),
|
||||
pipeline.nodes.findIndex((n) => n.id === nodeId),
|
||||
`${declared} must resolve to ${nodeId}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("does not stamp implementing for an edit to the lane spec", () => {
|
||||
// Stage 5 appends the QC Plan to docs/superpowers/specs/lane-<slug>.md.
|
||||
// Without the exclusion that edit reads as code work, and because
|
||||
// `implementing` sits early in the pipeline the mis-read is only invisible
|
||||
// by luck (forward-only) — on a fix-loop re-entry it would be the live stage.
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
for (const tool_name of ["Edit", "Write"]) {
|
||||
const r = detect(pipeline, {
|
||||
tool_name,
|
||||
tool_input: { file_path: "/lanes/lane1/docs/superpowers/specs/lane-foo.md" },
|
||||
});
|
||||
assert.notEqual(r && r.nodeId, "implementing", tool_name);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("never infers a terminal stage — merged and done are declaration-only", () => {
|
||||
// CLAUDE.md: an inferred node never renders `done`. Shipping a detect rule
|
||||
// for these nodes would be the one way to break that from the data side.
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
for (const id of ["merged", "done", "e2e-feature-passed", "reported", "qc-plan"]) {
|
||||
const node = pipeline.nodes.find((n) => n.id === id);
|
||||
assert.deepEqual(node.detect, [], `${id} must carry no detect rule`);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("does not read the word push, or an unrelated ccam call, as a later stage", () => {
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
for (const command of [
|
||||
'git commit -m "do not push this yet"',
|
||||
"ccam lanes logs 3 e2e",
|
||||
"ccam stage gates --status running",
|
||||
"gh pr diff 42",
|
||||
]) {
|
||||
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
|
||||
assert.equal(
|
||||
["publishing", "pr-open", "watching-pr", "e2e-feature"].includes(r && r.nodeId),
|
||||
false,
|
||||
`${command} → ${r && r.nodeId}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,21 +2,257 @@
|
||||
"id": "ship-feature",
|
||||
"name": "Ship feature (lane pipeline)",
|
||||
"nodes": [
|
||||
{ "id": "intake", "label": "intake", "icon": "📝", "gate": false, "aliases": [] },
|
||||
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false, "aliases": [] },
|
||||
{ "id": "implementing", "label": "implement (TDD)", "icon": "🛠", "gate": false, "aliases": [] },
|
||||
{ "id": "gates", "label": "CI gates + preflight", "icon": "🧪", "gate": true, "aliases": [] },
|
||||
{ "id": "e2e-feature", "label": "e2e on feature branch", "icon": "🧪", "gate": false, "aliases": [] },
|
||||
{ "id": "e2e-feature-passed", "label": "e2e passed", "icon": "🧪", "gate": true, "aliases": [] },
|
||||
{ "id": "review", "label": "code review", "icon": "👀", "gate": true, "aliases": [] },
|
||||
{ "id": "qc-plan", "label": "QC plan", "icon": "📋", "gate": false, "aliases": [] },
|
||||
{ "id": "qc", "label": "browser QC", "icon": "🔍", "gate": true, "aliases": [] },
|
||||
{ "id": "gate", "label": "senior GO/NO-GO gate", "icon": "🚦", "gate": true, "aliases": [] },
|
||||
{ "id": "publishing", "label": "publish PR", "icon": "🔀", "gate": false, "aliases": [] },
|
||||
{ "id": "pr-open", "label": "PR open", "icon": "🔀", "gate": false, "aliases": ["ship"] },
|
||||
{ "id": "reported", "label": "reported", "icon": "📣", "gate": false, "aliases": [] },
|
||||
{ "id": "watching-pr", "label": "watching PR", "icon": "👁", "gate": false, "aliases": [] },
|
||||
{ "id": "merged", "label": "merged — post-verify", "icon": "🔗", "gate": false, "aliases": [] },
|
||||
{ "id": "done", "label": "done", "icon": "✅", "gate": false, "aliases": ["complete", "completed"] }
|
||||
{
|
||||
"id": "intake",
|
||||
"label": "intake",
|
||||
"icon": "📝",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"assigned",
|
||||
"claimed",
|
||||
"start",
|
||||
"bootstrapping"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "brainstorming"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*\\bfeature activate\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "plan",
|
||||
"label": "plan",
|
||||
"icon": "🧭",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"planning",
|
||||
"brainstorm",
|
||||
"design"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "writing-plans"
|
||||
},
|
||||
{
|
||||
"tool": "Write",
|
||||
"match": "docs/superpowers/specs/lane-.*\\.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "implementing",
|
||||
"label": "implement (TDD)",
|
||||
"icon": "🛠",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"implement",
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "test-driven-development|executing-plans|subagent-driven-development|systematic-debugging"
|
||||
},
|
||||
{
|
||||
"tool": "Edit",
|
||||
"match": "^(?!.*docs/superpowers/specs/)"
|
||||
},
|
||||
{
|
||||
"tool": "Write",
|
||||
"match": "^(?!.*(?:^|/)docs/)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gates",
|
||||
"label": "CI gates + preflight",
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"pre-push-gate",
|
||||
"tests",
|
||||
"migration-collision",
|
||||
"sync-conflict"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*(\\bhook ci-gate\\b|\\bsync-base --check\\b)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e2e-feature",
|
||||
"label": "e2e on feature branch",
|
||||
"icon": "🧪",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"e2e",
|
||||
"e2e-scoped",
|
||||
"booting",
|
||||
"live"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*(\\bup --qc\\b|\\bhook e2e\\b)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e2e-feature-passed",
|
||||
"label": "e2e passed",
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"e2e-passed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"label": "code review",
|
||||
"icon": "👀",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"reviewing",
|
||||
"code-review",
|
||||
"self-review"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "code-review|requesting-code-review|receiving-code-review"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "qc-plan",
|
||||
"label": "QC plan",
|
||||
"icon": "📋",
|
||||
"gate": false,
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "qc",
|
||||
"label": "browser QC",
|
||||
"icon": "🔍",
|
||||
"gate": true,
|
||||
"aliases": [],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Agent",
|
||||
"match": "qc-local"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*\\blanes proof-link\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gate",
|
||||
"label": "senior GO/NO-GO gate",
|
||||
"icon": "🚦",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"sr-gate",
|
||||
"verify",
|
||||
"verification",
|
||||
"gate-blocked"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Agent",
|
||||
"match": "senior-gate-reviewer"
|
||||
},
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "verification-before-completion"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "publishing",
|
||||
"label": "publish PR",
|
||||
"icon": "🔀",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"push"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "finishing-a-development-branch"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pr-open",
|
||||
"label": "PR open",
|
||||
"icon": "🔀",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"ship",
|
||||
"pr",
|
||||
"push-conflict",
|
||||
"push-revalidate"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgh\\b[^;&|]*\\bpr create\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "reported",
|
||||
"label": "reported",
|
||||
"icon": "📣",
|
||||
"gate": false,
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "watching-pr",
|
||||
"label": "watching PR",
|
||||
"icon": "👁",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"pr-comment-fix"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgh\\b[^;&|]*\\bpr view\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "merged",
|
||||
"label": "merged — post-verify",
|
||||
"icon": "🔗",
|
||||
"gate": false,
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "done",
|
||||
"label": "done",
|
||||
"icon": "✅",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"complete",
|
||||
"completed"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+28
-3
@@ -10,7 +10,14 @@
|
||||
*/
|
||||
|
||||
const { db } = require("../db");
|
||||
const { getPipeline, phaseIdx, nodeStates, progressPct } = require("./pipelines");
|
||||
const {
|
||||
listPipelines,
|
||||
getPipeline,
|
||||
phaseIdx,
|
||||
stageRecords,
|
||||
nodeStates,
|
||||
progressPct,
|
||||
} = require("./pipelines");
|
||||
|
||||
const DEAD_SEC = Number(process.env.LANE_DEAD_SEC || 300);
|
||||
/**
|
||||
@@ -88,6 +95,18 @@ function validateKind(kind) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `getPipeline` falls back to the default template for an unknown id — correct
|
||||
* when READING (a lane must always render something), wrong when WRITING: a
|
||||
* typo'd id would be accepted, stored, and then silently draw the default map
|
||||
* forever. Reject it at the write, where the caller can still be told.
|
||||
*/
|
||||
function validatePipeline(id) {
|
||||
if (!listPipelines().some((p) => p.id === id)) {
|
||||
throw Object.assign(new Error(`unknown pipeline: ${id}`), { code: "EBADPIPELINE" });
|
||||
}
|
||||
}
|
||||
|
||||
function hydrate(row) {
|
||||
if (!row) return null;
|
||||
let stages = {};
|
||||
@@ -125,6 +144,7 @@ function createLane({
|
||||
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
||||
}
|
||||
validateKind(kind);
|
||||
validatePipeline(pipeline);
|
||||
const info = db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
@@ -156,6 +176,9 @@ function updateLane(id, patch = {}) {
|
||||
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
|
||||
validateKind(patch.kind);
|
||||
}
|
||||
if ("pipeline" in patch && patch.pipeline !== null && patch.pipeline !== undefined) {
|
||||
validatePipeline(patch.pipeline);
|
||||
}
|
||||
const cols = [];
|
||||
const vals = [];
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
@@ -441,14 +464,16 @@ function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
|
||||
* its own id: declaring by ALIAS (`ccam stage coding` → the `implement` node)
|
||||
* keys `stages` by the raw declared string, so the node the agent says it is on
|
||||
* would otherwise render as an inference instead of the blue `current` ring.
|
||||
* Past nodes go through `stageRecords`, which resolves those alias keys — a
|
||||
* node the agent DECLARED must never be painted as merely detected.
|
||||
*/
|
||||
function withDetected(states, pipeline, lane) {
|
||||
const detectedIdx = phaseIdx(pipeline, lane.detected_stage);
|
||||
if (detectedIdx === -1) return states.map((n) => ({ ...n, detected: false }));
|
||||
const stages = lane.stages || {};
|
||||
const records = stageRecords(pipeline, lane.stages);
|
||||
return states.map((n, i) => ({
|
||||
...n,
|
||||
detected: i <= detectedIdx && !stages[n.id] && n.state !== "current",
|
||||
detected: i <= detectedIdx && !records.has(i) && n.state !== "current",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -77,6 +77,27 @@ function phaseIdx(pipeline, stage) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every recorded stage by the node it resolves to, so a stage declared
|
||||
* under an ALIAS keeps its record — and therefore its evidence. Keying the
|
||||
* records by node id alone (what `lane.stages` is keyed by, verbatim from the
|
||||
* declaration) silently drops `--evidence` the moment an agent says `e2e`
|
||||
* instead of `e2e-feature`, which is exactly what aliases exist to allow.
|
||||
* A record stored under the node's own id always wins over an alias record for
|
||||
* the same node; a key matching no node is skipped.
|
||||
*/
|
||||
function stageRecords(pipeline, stages) {
|
||||
const byIdx = new Map();
|
||||
for (const [key, rec] of Object.entries(stages || {})) {
|
||||
const i = phaseIdx(pipeline, key);
|
||||
if (i === -1) continue;
|
||||
const isCanonical = key.toLowerCase() === pipeline.nodes[i].id.toLowerCase();
|
||||
if (byIdx.has(i) && !isCanonical) continue;
|
||||
byIdx.set(i, rec);
|
||||
}
|
||||
return byIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render state per node:
|
||||
* failed — the stage recorded result "fail"
|
||||
@@ -86,10 +107,10 @@ function phaseIdx(pipeline, stage) {
|
||||
* pending — not reached
|
||||
*/
|
||||
function nodeStates(pipeline, lane) {
|
||||
const stages = lane.stages || {};
|
||||
const records = stageRecords(pipeline, lane.stages);
|
||||
const cur = phaseIdx(pipeline, lane.stage);
|
||||
return pipeline.nodes.map((n, i) => {
|
||||
const rec = stages[n.id];
|
||||
const rec = records.get(i);
|
||||
let state;
|
||||
if (rec && rec.result === "fail") state = "failed";
|
||||
else if (i === cur) state = "current";
|
||||
@@ -111,6 +132,7 @@ module.exports = {
|
||||
listPipelines,
|
||||
getPipeline,
|
||||
phaseIdx,
|
||||
stageRecords,
|
||||
nodeStates,
|
||||
progressPct,
|
||||
reload,
|
||||
|
||||
@@ -268,9 +268,10 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
||||
} catch (err) {
|
||||
// A bad `kind` is invalid input, not a server fault — every sibling route
|
||||
// answers 400 here, so this one must too instead of throwing into Express.
|
||||
if (err.code === "EBADKIND") {
|
||||
// A bad `kind` or `pipeline` is invalid input, not a server fault — every
|
||||
// sibling route answers 400 here, so this one must too instead of throwing
|
||||
// into Express.
|
||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
|
||||
Reference in New Issue
Block a user