/** * @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ĩ */ 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"); 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 `__` — 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; } } }); }); 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); }); });