478418f602
What broke (code audit, untested on a Mac): - Without systemd the server is always spawned from inside a Claude Code session, so it inherited CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_PLUGIN_ROOT, ... and tmux handed them to every lane pane (a codex pane even resolved as `claude` in `agenthub`'s harness check). - The ~/.local/bin/agenthub launcher pinned a versioned node path that `brew upgrade`/nvm deletes, leaving the CLI dead until the next bootstrap. - A discovery entry left over from before a reboot could name a recycled pid, so the bootstrap's fast path believed a dashboard was live and stopDashboard could signal an unrelated process. - Lanes were stored under the typed cwd; macOS reports /private/tmp and the on-disk case, so sessions never bound to a /tmp or differently-cased lane. What changed: - server/index.js deletes a DENYLIST of parent-session variables at startup; user config (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) stays. - linkCli's launcher falls back to `node` on PATH when the pinned one is gone. - liveServers ignores entries whose startedAt predates this boot (60 s margin). - createLane and POST /api/lanes/ensure store/look up fs.realpathSync.native of an existing cwd; the liveness reaper also tries the realpath. Existing rows are untouched. - session-liveness gains an exec seam so the darwin ps+lsof branch is tested. - plugin-doctor names ~/.zshrc on darwin; INSTALL.md gains a macOS section with the Mac verification checklist, mirrored in both READMEs and PLUGINS.md. No launchd, no macOptionIsMeta, engines stays >=20.
1290 lines
54 KiB
JavaScript
1290 lines
54 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 `agenthub 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("per-node driver command", () => {
|
|
it("gives every built-in template's nodes a command field, and carries it into the render states", () => {
|
|
for (const pipeline of listPipelines()) {
|
|
for (const node of pipeline.nodes) {
|
|
assert.equal(
|
|
typeof node.command,
|
|
"string",
|
|
`${pipeline.id}/${node.id} must declare a command string`
|
|
);
|
|
}
|
|
const states = nodeStates(pipeline, { stage: pipeline.nodes[0].id, stages: {} });
|
|
assert.deepEqual(
|
|
states.map((n) => n.command),
|
|
pipeline.nodes.map((n) => n.command),
|
|
`${pipeline.id}: nodeStates must pass each node's command through`
|
|
);
|
|
}
|
|
});
|
|
|
|
it("names a real driver for the stages an agent actually drives", () => {
|
|
const ship = getPipeline("ship-feature");
|
|
const byId = Object.fromEntries(ship.nodes.map((n) => [n.id, n.command]));
|
|
|
|
assert.equal(byId.intake, "/superpowers:brainstorming");
|
|
assert.equal(byId.implementing, "/superpowers:test-driven-development");
|
|
assert.equal(byId.gates, "agenthub lanes hook ci-gate");
|
|
// A terminal or gate node has no work of its own to run: reaching it IS
|
|
// the declaration, so its command is the declaration - which keeps
|
|
// `agenthub lanes node <id>` runnable for every node in the template.
|
|
assert.equal(byId.done, "agenthub stage done");
|
|
assert.equal(byId["e2e-feature-passed"], "agenthub stage e2e-feature-passed");
|
|
|
|
// The 8-node `default` template drives the same way. `intake` in particular
|
|
// runs the brainstorm — it used to declare only `agenthub stage intake`,
|
|
// which made "intake" a label the pipeline walked past without ever asking
|
|
// the user a question.
|
|
const def = getPipeline("default");
|
|
const defById = Object.fromEntries(def.nodes.map((n) => [n.id, n.command]));
|
|
assert.equal(defById.intake, "/superpowers:brainstorming");
|
|
assert.equal(defById.plan, "/superpowers:writing-plans");
|
|
assert.equal(defById.implement, "/superpowers:test-driven-development");
|
|
assert.equal(defById.gate, "/superpowers:verification-before-completion");
|
|
assert.equal(defById.done, "agenthub stage done");
|
|
});
|
|
|
|
it("defaults a template node that declares no command to the empty string", () => {
|
|
// A custom template written before this field existed must keep loading -
|
|
// an undefined command would render as "undefined" in the UI.
|
|
const fs = require("node:fs");
|
|
const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "pipelines-nocmd-"));
|
|
fs.writeFileSync(
|
|
pathMod.join(dir, "legacy.json"),
|
|
JSON.stringify({
|
|
id: "legacy-nocmd",
|
|
name: "Legacy",
|
|
nodes: [
|
|
{ id: "start", label: "Start" },
|
|
{ id: "done", command: 42 },
|
|
],
|
|
})
|
|
);
|
|
const prev = process.env.DASHBOARD_PIPELINES_DIR;
|
|
process.env.DASHBOARD_PIPELINES_DIR = dir;
|
|
const { reload } = require("../lib/pipelines");
|
|
reload();
|
|
try {
|
|
const p = getPipeline("legacy-nocmd");
|
|
assert.deepEqual(
|
|
p.nodes.map((n) => n.command),
|
|
["", ""],
|
|
"a missing or non-string command must normalize to empty, never leak through"
|
|
);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.DASHBOARD_PIPELINES_DIR;
|
|
else process.env.DASHBOARD_PIPELINES_DIR = prev;
|
|
reload();
|
|
}
|
|
});
|
|
});
|
|
|
|
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("declaring an EARLIER stage drops the records of every node ahead of it", () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt/rollback" });
|
|
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
|
|
lanes.setStage(l.id, { stage: "tests", evidence: "npm test" });
|
|
lanes.setStage(l.id, { stage: "done", evidence: "shipped" });
|
|
|
|
// A new request arrives and the session declares back at `plan`. The old
|
|
// pass's tests/done records must not keep rendering `done` ahead of it.
|
|
const after = lanes.setStage(l.id, { stage: "plan" });
|
|
assert.equal(after.stages.tests, undefined);
|
|
assert.equal(after.stages.done, undefined);
|
|
assert.equal(after.stages.plan.evidence, "docs/plan.md"); // its own record survives
|
|
|
|
const nodes = lanes.lanePayload(after).pipeline_nodes;
|
|
assert.equal(nodes.find((n) => n.id === "plan").state, "current");
|
|
assert.equal(nodes.find((n) => n.id === "tests").state, "pending");
|
|
assert.equal(nodes.find((n) => n.id === "done").state, "pending");
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("moving FORWARD keeps every earlier record, and an unplaceable stage prunes nothing", () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt/rollback-fwd" });
|
|
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
|
|
const fwd = lanes.setStage(l.id, { stage: "tests", evidence: "npm test" });
|
|
assert.equal(fwd.stages.plan.evidence, "docs/plan.md");
|
|
|
|
// A stage matching no node has no position, so direction is unknown.
|
|
const typo = lanes.setStage(l.id, { stage: "implemnt" });
|
|
assert.equal(typo.stages.plan.evidence, "docs/plan.md");
|
|
assert.equal(typo.stages.tests.evidence, "npm test");
|
|
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, "/agenthub:ship-feature");
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("createLane defaults tool to claude and accepts codex", () => {
|
|
const a = lanes.createLane({ cwd: "/tmp/tool-default" });
|
|
assert.equal(a.tool, "claude");
|
|
const b = lanes.createLane({ cwd: "/tmp/tool-codex", tool: "codex" });
|
|
assert.equal(b.tool, "codex");
|
|
});
|
|
|
|
it("createLane rejects an unknown tool", () => {
|
|
assert.throws(
|
|
() => lanes.createLane({ cwd: "/tmp/tool-bad", tool: "cursor" }),
|
|
(err) => err.code === "EBADTOOL"
|
|
);
|
|
});
|
|
|
|
it("updateLane never writes tool (immutable per lane)", () => {
|
|
const lane = lanes.createLane({ cwd: "/tmp/tool-immutable", tool: "codex" });
|
|
const after = lanes.updateLane(lane.id, { tool: "claude", title: "x" });
|
|
assert.equal(after.tool, "codex");
|
|
assert.equal(after.title, "x");
|
|
});
|
|
|
|
// The Workspace's inline rename PATCHes `title` straight from a text field,
|
|
// so the column is now reachable by anything same-origin. A non-string would
|
|
// reach better-sqlite3 as a datatype throw and surface as a 500 on what is
|
|
// plain bad input.
|
|
it("normalizes a title on create and on update", () => {
|
|
const lane = lanes.createLane({ cwd: "/tmp/title-norm", title: " spaced " });
|
|
assert.equal(lane.title, "spaced");
|
|
assert.equal(lanes.updateLane(lane.id, { title: 42 }).title, "42");
|
|
assert.equal(lanes.updateLane(lane.id, { title: "x".repeat(500) }).title.length, 200);
|
|
// null clears rather than violating the NOT NULL, and the empty string is
|
|
// a real value: it puts the lane's cwd back in the UI's place for it.
|
|
assert.equal(lanes.updateLane(lane.id, { title: null }).title, "");
|
|
assert.equal(lanes.updateLane(lane.id, { title: "" }).title, "");
|
|
});
|
|
|
|
// Every free-text column, not just the one the rename field exposes: a
|
|
// non-scalar reaches better-sqlite3 as a datatype throw, which Express can
|
|
// only report as a 500 on what is plainly the caller's mistake.
|
|
it("rejects a non-scalar patch value on any patchable column", () => {
|
|
const lane = lanes.createLane({ cwd: "/tmp/patch-scalar" });
|
|
for (const patch of [
|
|
{ title: { evil: true } },
|
|
{ notes: { a: 1 } },
|
|
{ branch: ["main"] },
|
|
{ ci_status: true },
|
|
{ session_id: () => "x" },
|
|
]) {
|
|
assert.throws(
|
|
() => lanes.updateLane(lane.id, patch),
|
|
(err) => err.code === "EBADFIELD",
|
|
`expected EBADFIELD for ${JSON.stringify(Object.keys(patch))}`
|
|
);
|
|
}
|
|
assert.throws(
|
|
() => lanes.createLane({ cwd: "/tmp/patch-scalar-create", title: { evil: true } }),
|
|
(err) => err.code === "EBADFIELD"
|
|
);
|
|
// `links` is the one patchable column that IS an object — it is stored as
|
|
// JSON, so it must keep working.
|
|
assert.deepEqual(lanes.updateLane(lane.id, { links: { pr: "u" } }).links, { pr: "u" });
|
|
});
|
|
});
|
|
|
|
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("a declaration on the pipeline's LAST node stops vetoing detection", () => {
|
|
// The pipeline is over, so a later detection is new work — not a stale
|
|
// rerun of work the declaration already covers. Without this a lane left
|
|
// at `done` can never be detected again for the rest of its life.
|
|
const l = lanes.createLane({ cwd: "/tmp/wt-detect-terminal" });
|
|
lanes.setStage(l.id, { stage: "done" });
|
|
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
|
|
written: true,
|
|
});
|
|
const after = lanes.getLane(l.id);
|
|
assert.equal(after.detected_stage, "implement");
|
|
assert.equal(after.stage, "done"); // detection never moves the declaration
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("a declaration on the last node's ALIAS also stops vetoing", () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt-detect-terminal-alias" });
|
|
lanes.setStage(l.id, { stage: "completed" });
|
|
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement" }), { written: true });
|
|
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" and `stages` is empty, so the lane
|
|
// has no declaration of its own to render and is displayed AT its detected
|
|
// node instead (inferredDisplayStage) — `current`, and flagged `detected`
|
|
// so it paints amber rather than as a declaration.
|
|
const review = payload.pipeline_nodes.find((n) => n.id === "review");
|
|
assert.equal(review.state, "current");
|
|
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` on a lane that HAS declared a stage", () => {
|
|
const { db } = require("../db");
|
|
const l = lanes.createLane({ cwd: "/tmp/wt-detect-stale-render" });
|
|
lanes.setStage(l.id, { stage: "plan" });
|
|
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"
|
|
);
|
|
// The declaration is what the lane still renders from.
|
|
assert.equal(nodes.find((n) => n.id === "plan").state, "current");
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("a lane that never declared a stage keeps rendering its LAST detection, TTL or not", () => {
|
|
const { db } = require("../db");
|
|
const l = lanes.createLane({ cwd: "/tmp/wt-detect-undeclared-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);
|
|
|
|
// With nothing declared, the last detection IS the lane's last known
|
|
// progress — blanking it left every non-AgentHub repo's card 100% pending
|
|
// forever. Still amber (`detected`), still never `done`.
|
|
const payload = lanes.lanePayload(lanes.getLane(l.id));
|
|
const tests = payload.pipeline_nodes.find((n) => n.id === "tests");
|
|
assert.equal(tests.state, "current");
|
|
assert.equal(tests.detected, true);
|
|
assert.ok(!payload.pipeline_nodes.some((n) => n.state === "done"));
|
|
assert.ok(payload.progress > 0, "an inferred stage advances the progress bar");
|
|
// The declared stage the DB holds is reported untouched.
|
|
assert.equal(payload.stage, "idle");
|
|
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);
|
|
});
|
|
|
|
it("a terminal declaration still honors forward-only among detections", () => {
|
|
// The exception for terminal nodes is: a NEW detection can land after a
|
|
// terminal declaration. But forward-only WITHIN that detection batch still
|
|
// holds — you cannot move backwards among detections.
|
|
const l = lanes.createLane({ cwd: "/tmp/wt-terminal-forward" });
|
|
lanes.setStage(l.id, { stage: "done" });
|
|
lanes.recordDetection(l.id, { nodeId: "ship" }); // ahead of everything
|
|
const result = lanes.recordDetection(l.id, { nodeId: "review" }); // behind the just-recorded detection
|
|
assert.deepEqual(result, { written: false, reason: "behind-detected" });
|
|
assert.equal(lanes.getLane(l.id).detected_stage, "ship");
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("the terminal-node exception works on the 16-node ship-feature template too", () => {
|
|
const l = lanes.createLane({
|
|
cwd: "/tmp/wt-terminal-16node",
|
|
pipeline: "ship-feature",
|
|
});
|
|
lanes.setStage(l.id, { stage: "done" });
|
|
// The ship-feature template's last node is also "done", so detection should work
|
|
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
|
|
written: true,
|
|
});
|
|
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
|
|
assert.equal(lanes.getLane(l.id).stage, "done"); // stage unchanged
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("detection never writes stage, even after terminal declaration", () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt-terminal-no-stage-write" });
|
|
lanes.setStage(l.id, { stage: "done" });
|
|
const before = lanes.getLane(l.id);
|
|
lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" });
|
|
const after = lanes.getLane(l.id);
|
|
// stage and stage_since must not change
|
|
assert.equal(after.stage, before.stage);
|
|
assert.equal(after.stage_since, before.stage_since);
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("a node with detection never renders done in the UI, even after terminal declaration", () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt-terminal-no-done-render" });
|
|
lanes.setStage(l.id, { stage: "done" });
|
|
lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" });
|
|
const lane = lanes.getLane(l.id);
|
|
const payload = lanes.lanePayload(lane);
|
|
// The detected implement node must be shown as pending + detected, not done
|
|
const implementNode = payload.pipeline_nodes.find((n) => n.id === "implement");
|
|
assert.equal(implementNode.detected, true);
|
|
assert.notEqual(implementNode.state, "done");
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("behind-detected is still returned when appropriate after terminal declaration", () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt-terminal-behind-detected" });
|
|
lanes.setStage(l.id, { stage: "done" });
|
|
lanes.recordDetection(l.id, { nodeId: "tests" });
|
|
// Next detection is behind the current detected stage
|
|
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
|
|
assert.deepEqual(result, { written: false, reason: "behind-detected" });
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("cwd-resolved single lane still resolves without flags and no env marker", () => {
|
|
// This is a basic sanity check: a lane at a cwd with no sibling lane of
|
|
// another tool should resolve with just the cwd and no --tool flag.
|
|
const cwd = "/tmp/wt-single-resolve";
|
|
const l = lanes.createLane({ cwd, title: "solo", tool: "claude" });
|
|
const result = lanes.resolveLaneByCwd(cwd);
|
|
assert.ok(result, "lane should resolve by cwd");
|
|
assert.equal(result.id, l.id);
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("nested cwd resolution picks the deepest lane", () => {
|
|
const outer = "/tmp/wt-nested-outer";
|
|
const inner = "/tmp/wt-nested-outer/sub/deep";
|
|
const outerLane = lanes.createLane({ cwd: outer, title: "outer" });
|
|
const innerLane = lanes.createLane({ cwd: inner, title: "inner" });
|
|
// Resolving from a path deep inside inner should pick inner
|
|
const result = lanes.resolveLaneByCwd(`${inner}/src/file.js`);
|
|
assert.equal(result.id, innerLane.id);
|
|
lanes.deleteLane(innerLane.id);
|
|
lanes.deleteLane(outerLane.id);
|
|
});
|
|
|
|
it("resolveLaneByCwd with explicit tool filter returns null if no lane matches", () => {
|
|
const cwd = "/tmp/wt-resolve-tool-filter";
|
|
lanes.createLane({ cwd, title: "claude-only", tool: "claude" });
|
|
// Explicitly request a codex lane at this cwd when only claude exists
|
|
const result = lanes.resolveLaneByCwd(cwd, "codex");
|
|
assert.equal(result, null);
|
|
});
|
|
});
|
|
|
|
describe("createLane — canonical cwd (macOS /tmp -> /private/tmp class)", () => {
|
|
it("stores the realpath of a symlinked dir, and the given string when it does not exist yet", () => {
|
|
const fs = require("node:fs");
|
|
const base = fs.realpathSync(fs.mkdtempSync(pathMod.join(os.tmpdir(), "lane-realpath-")));
|
|
const target = pathMod.join(base, "real");
|
|
const link = pathMod.join(base, "link");
|
|
fs.mkdirSync(target);
|
|
fs.symlinkSync(target, link);
|
|
try {
|
|
assert.equal(lanes.createLane({ cwd: link + "/" }).cwd, target);
|
|
const missing = pathMod.join(base, "not-yet");
|
|
assert.equal(lanes.createLane({ cwd: missing }).cwd, missing);
|
|
} finally {
|
|
fs.rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|