Files
AgentHub/server/__tests__/lanes-lib.test.js
T
nntrivi2001 2103dde889 feat(lanes): drive runs from the pipeline template's own skill
A pipeline template may now declare the slash command that drives it
(`skill`), so the choice of pipeline is the choice of workflow rather
than just a picture on the dashboard:

- `ccam ship-full "<req>"` runs the 16-node `ship-feature` template's
  skill (`/ccam:ship-feature-lane`) — through gates, QC and the senior
  gate to a published PR, watched until a human merges it.
- `ccam ship-quick "<req>"` runs the 8-node `default` template's skill
  (`/ccam:ship-feature`) — in-repo feature work that stops at the gate.

Each command puts the lane on its own template before spawning, so the
dashboard renders the right node count from the first stage instead of
after the skill self-corrects. `--print` shows the command, switches
nothing and runs nothing. The prompt reaches `claude` as one argv
element, never through a shell.

The same field feeds the Workspace run console, which pre-fills its
prompt with the lane's driving skill and never overwrites a prompt the
user has already edited.
2026-08-24 16:34:55 +07:00

956 lines
39 KiB
JavaScript

/**
* @file Unit tests for pipeline templates and durable lane storage helpers,
* including lifecycle transitions, recovery, liveness, and worktree metadata.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const pathMod = require("node:path");
// Every test in this file must hit THIS database. Migration tests below
// re-point DASHBOARD_DB_PATH at a throwaway file; their cleanup must restore
// this value rather than delete it - an unset DASHBOARD_DB_PATH resolves to the
// operator's REAL dashboard DB, so a later test would read and write live data.
const SUITE_DB_PATH = pathMod.join(
os.tmpdir(),
`dashboard-lanes-lib-${Date.now()}-${process.pid}.db`
);
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
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("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);
assert.equal(progressPct(p, { stage: p.nodes[p.nodes.length - 1].id, stages: {} }), 100);
assert.equal(progressPct(p, { stage: "nope", stages: {} }), 0);
});
});
const lanes = require("../lib/lanes");
const features = require("../lib/lane-features");
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("recovers only provisioning lanes interrupted by a restart", () => {
const provisioning = lanes.createLane({
cwd: "/tmp/wt/provisioning-recovery",
branch: "feat/provisioning-recovery",
kind: "managed",
});
lanes.updateLane(provisioning.id, { status: "provisioning" });
const idle = lanes.createLane({ cwd: "/tmp/wt/idle-recovery", branch: "feat/idle-recovery" });
const failed = lanes.createLane({
cwd: "/tmp/wt/failed-recovery",
branch: "feat/failed-recovery",
});
lanes.updateLane(failed.id, { status: "failed", notes: "existing failure" });
assert.equal(lanes.recoverInterruptedProvisioning(), 1);
const recovered = lanes.getLane(provisioning.id);
assert.equal(recovered.status, "failed");
assert.equal(recovered.notes, "Provisioning was interrupted by a server restart.");
assert.equal(recovered.kind, "managed");
assert.equal(recovered.cwd, "/tmp/wt/provisioning-recovery");
assert.equal(recovered.branch, "feat/provisioning-recovery");
assert.equal(lanes.getLane(idle.id).status, "idle");
assert.equal(lanes.getLane(failed.id).status, "failed");
assert.equal(lanes.getLane(failed.id).notes, "existing failure");
lanes.deleteLane(provisioning.id);
lanes.deleteLane(idle.id);
lanes.deleteLane(failed.id);
});
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");
// The template's driving slash command rides along so the run console can
// pre-fill its prompt with it.
assert.equal(p.pipeline_skill, "/ccam:ship-feature");
lanes.deleteLane(l.id);
});
});
const { withLaneLock } = require("../lib/lane-lock");
describe("lane kind, worktree fields and purge", () => {
it("defaults to adopted and stores worktree fields when given", () => {
const a = lanes.createLane({ cwd: "/tmp/wt-kind-a" });
assert.equal(a.kind, "adopted");
const m = lanes.createLane({
cwd: "/tmp/wt-kind-b",
kind: "managed",
source_repo: "/tmp/src",
base_branch: "main",
slug: "b",
});
assert.equal(m.kind, "managed");
assert.equal(m.source_repo, "/tmp/src");
assert.equal(m.base_branch, "main");
assert.equal(m.slug, "b");
lanes.deleteLane(a.id);
lanes.deleteLane(m.id);
});
it("rejects an unknown kind", () => {
assert.throws(
() => lanes.createLane({ cwd: "/tmp/wt-kind-c", kind: "gremlin" }),
(e) => e.code === "EBADKIND"
);
});
it("purges a lane's sessions, their events and orphaned token rows, sparing the live one", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-purge" });
const { db } = require("../db");
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"purge-1",
"/tmp/wt-purge/sub"
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run(
"purge-live",
"/tmp/wt-purge"
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'PostToolUse')").run(
"purge-1"
);
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 5)").run(
"purge-1"
);
lanes.updateLane(l.id, { session_id: "purge-live" });
const counts = lanes.purgeLaneSessions(l.id);
assert.equal(counts.sessions, 1);
assert.equal(counts.events, 1);
assert.equal(counts.tokenRows, 1);
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='purge-live'").get().c, 1);
assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='purge-1'").get().c, 0);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM token_usage WHERE session_id='purge-1'").get().c,
0
);
lanes.deleteLane(l.id);
});
it("never counts or deletes a sibling directory whose name differs only at an underscore", () => {
// Every managed lane directory is named `<repo>__<slug>` — two literal
// underscores, each of which LIKE treats as "any single character". Without
// ESCAPE, a lane at /root/myrepo__feat-foo purged /root/myrepoXXfeat-foo's
// sessions too, and the preflight count reported the victims, so the
// confirmation was consistently wrong rather than detectably wrong.
const { db } = require("../db");
const laneCwd = "/root/myrepo__feat-foo";
const siblingCwd = "/root/myrepoXXfeat-foo";
const l = lanes.createLane({ cwd: laneCwd, kind: "managed" });
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"like-mine",
`${laneCwd}/sub`
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"like-victim",
`${siblingCwd}/sub`
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run(
"like-victim-active",
`${siblingCwd}/other`
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'Stop')").run("like-victim");
// The counter sees only this lane's own session.
const candidates = lanes.purgeCandidateSessions(lanes.getLane(l.id));
assert.deepEqual(
candidates.map((s) => s.id),
["like-mine"]
);
// ...and does not attribute the sibling's live session to this lane either.
assert.equal(lanes.hasActiveLaneSession(lanes.getLane(l.id)), false);
// The deleter agrees with the counter, exactly.
const counts = lanes.purgeLaneSessions(l.id);
assert.equal(counts.sessions, 1);
assert.equal(counts.events, 0);
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='like-victim'").get().c, 1);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='like-victim-active'").get().c,
1
);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='like-victim'").get().c,
1
);
lanes.deleteLane(l.id);
});
it("a PATCH-style updateLane cannot change kind, source_repo, slug or base_branch", () => {
// kind is check 1 of the destroy guard: a client that could flip it to
// "managed" could point the guard at a directory the user owns.
const l = lanes.createLane({
cwd: "/tmp/wt-patch-guard",
kind: "adopted",
source_repo: "/tmp/original-src",
base_branch: "main",
slug: "original",
});
const after = lanes.updateLane(l.id, {
kind: "managed",
source_repo: "/tmp/attacker-src",
base_branch: "attacker",
slug: "attacker",
title: "this one IS patchable",
});
assert.equal(after.kind, "adopted");
assert.equal(after.source_repo, "/tmp/original-src");
assert.equal(after.base_branch, "main");
assert.equal(after.slug, "original");
assert.equal(after.title, "this one IS patchable");
// Provisioning has its own internal writer for those facts.
const provisioned = lanes.setProvisioningFacts(l.id, { base_branch: "resolved-base" });
assert.equal(provisioned.base_branch, "resolved-base");
assert.equal(provisioned.kind, "adopted");
lanes.deleteLane(l.id);
});
it("serialises work per lane and releases the lock when the body throws", async () => {
const order = [];
const slow = withLaneLock(7, async () => {
order.push("a-start");
await new Promise((r) => setTimeout(r, 50));
order.push("a-end");
});
const fast = withLaneLock(7, async () => {
order.push("b");
});
await Promise.all([slow, fast]);
assert.deepEqual(order, ["a-start", "a-end", "b"]);
await assert.rejects(() =>
withLaneLock(7, async () => {
throw new Error("boom");
})
);
await withLaneLock(7, async () => order.push("c"));
assert.equal(order[order.length - 1], "c");
});
it("updateLane rejects an unknown kind with EBADKIND and leaves stored value unchanged", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-kind-update", kind: "adopted" });
assert.equal(l.kind, "adopted");
assert.throws(
() => lanes.updateLane(l.id, { kind: "MANAGED" }),
(e) => e.code === "EBADKIND"
);
const after = lanes.getLane(l.id);
assert.equal(after.kind, "adopted");
lanes.deleteLane(l.id);
});
it("different lane ids have separate locks and run in parallel", async () => {
const order = [];
const lane1Slow = withLaneLock(10, async () => {
order.push("lane1-start");
await new Promise((r) => setTimeout(r, 50));
order.push("lane1-end");
});
const lane2 = withLaneLock(11, async () => {
order.push("lane2");
});
await Promise.all([lane1Slow, lane2]);
assert.ok(order.includes("lane2"), "lane 2 should run without waiting for lane 1");
assert.notDeepEqual(order, ["lane1-start", "lane1-end", "lane2"]);
});
it("migration: old schema database gains all four columns idempotently", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
`test-lanes-migration-${Date.now()}-${process.pid}.db`
);
try {
// Create an old-schema lanes table with only id, cwd, title columns
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE 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'))
)
`);
oldDb
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, stage_since) VALUES (?, ?, ?, ?, ?)"
)
.run("old-lane", "/tmp/old-lane-path", "main", "default", "2026-07-27T00:00:00Z");
oldDb.close();
// Load db.js against it, which should add the four columns
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
// Verify all four columns exist
const checkCols = [
"SELECT kind FROM lanes LIMIT 1",
"SELECT source_repo FROM lanes LIMIT 1",
"SELECT base_branch FROM lanes LIMIT 1",
"SELECT slug FROM lanes LIMIT 1",
];
checkCols.forEach((sql) => {
assert.doesNotThrow(() => newDb.prepare(sql).get());
});
// Verify existing row has kind='adopted' and others NULL
const row = newDb.prepare("SELECT * FROM lanes WHERE cwd = ?").get("/tmp/old-lane-path");
assert.equal(row.kind, "adopted");
assert.equal(row.source_repo, null);
assert.equal(row.base_branch, null);
assert.equal(row.slug, null);
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
});
describe("stage detection", () => {
it("writes a forward detection", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-forward" });
const result = lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" });
assert.deepEqual(result, { written: true });
const after = lanes.getLane(l.id);
assert.equal(after.detected_stage, "implement");
assert.equal(after.detected_signal, "`Edit`");
assert.ok(after.detected_at);
lanes.deleteLane(l.id);
});
it("drops a detection that does not advance past the current detected stage", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-backward" });
lanes.recordDetection(l.id, { nodeId: "tests" });
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("drops a detection at or behind the declared stage", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-declared" });
lanes.setStage(l.id, { stage: "review" });
const atDeclared = lanes.recordDetection(l.id, { nodeId: "review" });
assert.deepEqual(atDeclared, { written: false, reason: "behind-declared" });
const behindDeclared = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(behindDeclared, { written: false, reason: "behind-declared" });
assert.equal(lanes.getLane(l.id).detected_stage, null);
lanes.deleteLane(l.id);
});
it("reports unknown-node for a node id the pipeline does not have", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-unknown" });
const result = lanes.recordDetection(l.id, { nodeId: "not-a-real-node" });
assert.deepEqual(result, { written: false, reason: "unknown-node" });
assert.equal(lanes.getLane(l.id).detected_stage, null);
lanes.deleteLane(l.id);
});
it("PREMISE GUARD: a lane with only detections and no declarations has no done node", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-no-done" });
lanes.recordDetection(l.id, { nodeId: "review", signal: "`git diff`" });
const payload = lanes.lanePayload(lanes.getLane(l.id));
assert.ok(
!payload.pipeline_nodes.some((n) => n.state === "done"),
"inference must never render a node done — only a declared stage carrying evidence may"
);
// lane.stage is still the default "idle", which is not itself a pipeline
// node/alias, so nodeStates() (unaware of detection) reports "pending" for
// every node here — detection only ever adds the `detected` flag alongside
// whatever nodeStates() already computed from the declared stage.
const review = payload.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "pending");
assert.equal(review.detected, true);
const implement = payload.pipeline_nodes.find((n) => n.id === "implement");
assert.equal(implement.detected, true);
const done = payload.pipeline_nodes.find((n) => n.id === "done");
assert.equal(done.detected, false);
lanes.deleteLane(l.id);
});
it("PREMISE GUARD: a node declared WITH evidence stays done and unflagged under a detection ahead of it", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-done-guard" });
// implement declared with evidence, then the lane declares tests; a review
// detection lands ahead of both. `implement` is the adversarial node: it is
// `done` (declared + evidence) and it sits before the detected index.
lanes.setStage(l.id, { stage: "implement", evidence: "server/lib/x.js" });
lanes.setStage(l.id, { stage: "tests" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "review", signal: "`git diff`" }), {
written: true,
});
const nodes = lanes.lanePayload(lanes.getLane(l.id)).pipeline_nodes;
const implement = nodes.find((n) => n.id === "implement");
assert.equal(implement.state, "done");
assert.equal(implement.detected, false);
lanes.deleteLane(l.id);
});
it("a stage declared by ALIAS keeps its current ring instead of reading as an inference", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-alias-current" });
// "coding" is an alias of the `implement` node, so `stages` gets no entry
// under "implement" — the declared current node must still not be flagged.
lanes.setStage(l.id, { stage: "coding", evidence: "server/lib/x.js" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }), {
written: true,
});
const nodes = lanes.lanePayload(lanes.getLane(l.id)).pipeline_nodes;
const implement = nodes.find((n) => n.id === "implement");
assert.equal(implement.state, "current");
assert.equal(implement.detected, false);
lanes.deleteLane(l.id);
});
it("never writes the declared stage: stage and stage_since are byte-identical after a detection", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-no-stage-write" });
lanes.setStage(l.id, { stage: "plan" });
const before = lanes.getLane(l.id);
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }), {
written: true,
});
const after = lanes.getLane(l.id);
assert.equal(after.stage, before.stage);
assert.equal(after.stage_since, before.stage_since);
lanes.deleteLane(l.id);
});
it("clearLane nulls the detection columns and lets a fresh detection land immediately", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-clear" });
lanes.recordDetection(l.id, { nodeId: "ship", signal: "`git push`" });
assert.equal(lanes.getLane(l.id).detected_stage, "ship");
const cleared = lanes.clearLane(l.id);
assert.equal(cleared.detected_stage, null);
assert.equal(cleared.detected_signal, null);
assert.equal(cleared.detected_at, null);
assert.ok(
!lanes.lanePayload(cleared).pipeline_nodes.some((n) => n.detected),
"a cleared lane must claim no inferred progress"
);
// Without the reset, `ship` would forever outrank every later detection.
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
describe("clearLane archives the active feature first", () => {
it("archives the active feature with its final stage before resetting the live row", () => {
const l = lanes.createLane({ title: "t", cwd: "/tmp/wt-archive-active" });
features.activateFeature(l.id, "one");
lanes.setStage(l.id, { stage: "review", evidence: "e" });
lanes.clearLane(l.id);
const archived = features.getFeature(l.id, "one");
assert.notEqual(archived.archived_at, null);
assert.equal(archived.stage, "review");
const cleared = lanes.getLane(l.id);
assert.equal(cleared.stage, "idle");
assert.equal(cleared.active_feature_id, null);
lanes.deleteLane(l.id);
});
it("is unchanged for a lane that never activated a feature (no archive row created)", () => {
const l = lanes.createLane({ title: "t2", cwd: "/tmp/wt-archive-none" });
lanes.setStage(l.id, { stage: "review" });
lanes.clearLane(l.id);
const cleared = lanes.getLane(l.id);
assert.equal(cleared.stage, "idle");
lanes.deleteLane(l.id);
});
});
it("a real stage transition clears a stale detection and unblocks a fresh one behind it", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-stale-cross-task" });
// A prior, unrelated task's tool calls left `tests` as the detected stage.
lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
// A new task starts and the agent declares an EARLIER stage. Without the
// fix, the leftover `tests` detection both misrepresents the new task's
// progress and (via forward-only) rejects every real detection for it
// until `tests` ages past DETECTION_TTL_MS.
const after = lanes.setStage(l.id, { stage: "plan" });
assert.equal(after.detected_stage, null);
assert.equal(after.detected_signal, null);
assert.equal(after.detected_at, null);
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("re-declaring the SAME stage (a heartbeat) leaves a live detection alone", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-heartbeat-noop" });
lanes.setStage(l.id, { stage: "plan" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
lanes.setStage(l.id, { stage: "plan", note: "still planning" });
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("migration: detection columns are added to a database holding an old-schema lanes row", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
`test-lanes-detect-migration-${Date.now()}-${process.pid}.db`
);
try {
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE 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')),
kind TEXT NOT NULL DEFAULT 'adopted',
source_repo TEXT,
base_branch TEXT,
slug TEXT
)
`);
oldDb
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, stage_since) VALUES (?, ?, ?, ?, ?)"
)
.run(
"pre-detect-lane",
"/tmp/pre-detect-lane-path",
"main",
"default",
"2026-07-27T00:00:00Z"
);
oldDb.close();
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
["detected_stage", "detected_signal", "detected_at"].forEach((col) => {
assert.doesNotThrow(() => newDb.prepare(`SELECT ${col} FROM lanes LIMIT 1`).get());
});
const row = newDb
.prepare("SELECT * FROM lanes WHERE cwd = ?")
.get("/tmp/pre-detect-lane-path");
assert.equal(row.detected_stage, null);
assert.equal(row.detected_signal, null);
assert.equal(row.detected_at, null);
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
it("migration: a crash after only the first detection column self-heals on the next load", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
`test-lanes-detect-crash-${Date.now()}-${process.pid}.db`
);
try {
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE 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')),
kind TEXT NOT NULL DEFAULT 'adopted',
source_repo TEXT,
base_branch TEXT,
slug TEXT
)
`);
// Simulate a process death that landed only the first ALTER.
oldDb.exec("ALTER TABLE lanes ADD COLUMN detected_stage TEXT");
oldDb.close();
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
["detected_stage", "detected_signal", "detected_at"].forEach((col) => {
assert.doesNotThrow(() => newDb.prepare(`SELECT ${col} FROM lanes LIMIT 1`).get());
});
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
it("detection expiry: backward detection inside window is rejected", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-window" });
// Set an initial forward detection
lanes.recordDetection(l.id, { nodeId: "tests" });
// Try backward detection - should still be rejected within window
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("detection expiry: backward detection beyond TTL is accepted and written", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-stale" });
// Set an initial forward detection with a stale timestamp
lanes.recordDetection(l.id, { nodeId: "tests" });
// Manually set detected_at to an old time (2 hours ago, assuming default TTL of 30 min)
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id);
// Try backward detection - should be accepted because detection is stale
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: true });
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("detection expiry: stale detection behind declared stage still rejects", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-declared" });
lanes.setStage(l.id, { stage: "review" });
// setStage now clears detected_* on every real transition (a fresh
// declaration supersedes older inference), so a standing detection is
// written directly here, bypassing that, to isolate the rule this test
// is actually about: declared-wins is never relaxed by staleness, no
// matter how the stale detection got there.
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_stage = ?, detected_at = ? WHERE id = ?").run(
"tests",
oldTime,
l.id
);
// Try backward detection to implement - should still be rejected because declared stage is ahead
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-declared" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("detection expiry: NULL detected_at is treated as stale", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-null" });
// Set a detection and then nullify detected_at
lanes.recordDetection(l.id, { nodeId: "tests" });
db.prepare("UPDATE lanes SET detected_at = NULL WHERE id = ?").run(l.id);
// Try backward detection - should be accepted because detected_at is NULL (unknown age)
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: true });
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("detection expiry: DETECTION_TTL_MS env var controls the window", () => {
const { db } = require("../db");
const oldTTL = process.env.DETECTION_TTL_MS;
try {
// Set a very short TTL (1 second)
process.env.DETECTION_TTL_MS = "1000";
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-ttl" });
// Set initial detection
lanes.recordDetection(l.id, { nodeId: "tests" });
// Set detected_at to 2 seconds ago (beyond the 1-second TTL)
const staleTime = new Date(Date.now() - 2000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(staleTime, l.id);
// Try backward detection - should be accepted because we're beyond TTL
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: true });
lanes.deleteLane(l.id);
} finally {
if (oldTTL === undefined) {
delete process.env.DETECTION_TTL_MS;
} else {
process.env.DETECTION_TTL_MS = oldTTL;
}
}
});
it("a stale detection stops rendering as `detected` in pipeline_nodes, even though the DB row still holds it", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-stale-render" });
lanes.recordDetection(l.id, { nodeId: "tests" });
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id);
const lane = lanes.getLane(l.id);
// The TTL only gates future writes; the stale row is still there until a
// fresh detection or a real stage transition overwrites it.
assert.equal(lane.detected_stage, "tests");
const nodes = lanes.lanePayload(lane).pipeline_nodes;
assert.ok(
!nodes.some((n) => n.detected),
"a detection older than the TTL must not paint any node amber — it is no longer current information"
);
lanes.deleteLane(l.id);
});
});
describe("the forward-only hold is short enough for one work session", () => {
it("lets a newer signal move the lane back within a few minutes", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-hold-window" });
// A real session cycles implement -> tests -> ship -> implement -> tests.
// With a 30-minute hold, one push pinned the lane at `ship` for half an
// hour while the agent was demonstrably back to running tests.
lanes.recordDetection(l.id, { nodeId: "ship" });
const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(sixMinutesAgo, l.id);
const result = lanes.recordDetection(l.id, { nodeId: "tests", signal: "node --test" });
assert.deepEqual(result, { written: true });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("still smooths noise inside one burst of activity", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-hold-burst" });
lanes.recordDetection(l.id, { nodeId: "tests" });
// Same second: a Read after an Edit must not drag the lane backwards.
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
});