feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent

- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
This commit is contained in:
2026-09-25 17:30:28 +07:00
parent 3f6e903c22
commit 952e8584f0
48 changed files with 3336 additions and 1579 deletions
+74 -437
View File
@@ -1,7 +1,7 @@
/**
* @file Unit tests for Codex stage ingestion: the payload→hook-event mapping,
* and one full pass over a fixture rollout journal (detection + status mirror +
* broadcast + the no-new-bytes no-op).
* @file Unit tests for the Codex lane status mirror: one pass over fixture
* rollout journals flips a codex lane idle↔running, broadcasts it, and never
* touches a lane's stage.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -26,87 +26,11 @@ function line(payload, type = "response_item") {
return JSON.stringify({ timestamp: new Date().toISOString(), ordinal: 1, type, payload });
}
describe("codex payload mapping", () => {
it("maps a function_call exec_command to a Bash event", () => {
const events = codex.eventsFromPayload({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: "/repo" }),
});
assert.deepEqual(events, [
{ tool_name: "Bash", tool_input: { command: "npm run test:server" } },
]);
});
it("maps a custom_tool_call exec to a Bash event carrying the raw script", () => {
const [event] = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input: 'const r = await tools.exec_command({cmd:"git push origin feat/x"}); text(r.output);',
});
assert.equal(event.tool_name, "Bash");
assert.match(event.tool_input.command, /git push origin feat\/x/);
});
it("maps an apply_patch script to one event per touched file", () => {
const events = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input:
'const patch = "*** Begin Patch\n*** Update File: server/lib/lanes.js\n+x\n' +
'*** Add File: docs/NEW.md\n+y\n*** End Patch";',
});
assert.deepEqual(events, [
{ tool_name: "Edit", tool_input: { file_path: "server/lib/lanes.js" } },
{ tool_name: "Write", tool_input: { file_path: "docs/NEW.md" } },
]);
});
it("maps an apply_patch script whose newlines are still escaped", () => {
// Verbatim from a real rollout: the patch heredoc lives inside a JS string
// literal, so after JSON.parse the line breaks are a literal backslash-n,
// not newlines. Matching only real newlines silently sees no files at all.
const events = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input:
'const patch = "*** Begin Patch\\n*** Add File: config/demo.yml\\n+# demo\\n*** End Patch";',
});
assert.deepEqual(events, [
{ tool_name: "Write", tool_input: { file_path: "config/demo.yml" } },
]);
});
it("maps update_plan to TaskCreate and spawn_agent to Agent", () => {
assert.deepEqual(
codex.eventsFromPayload({ type: "function_call", name: "update_plan", arguments: "{}" }),
[{ tool_name: "TaskCreate", tool_input: {} }]
);
assert.deepEqual(
codex.eventsFromPayload({
type: "function_call",
name: "spawn_agent",
arguments: JSON.stringify({ task_name: "code_reviewer", message: "…" }),
}),
[{ tool_name: "Agent", tool_input: { subagent_type: "code_reviewer" } }]
);
});
it("ignores anything that is not a tool call", () => {
assert.deepEqual(codex.eventsFromPayload({ type: "reasoning" }), []);
assert.deepEqual(
codex.eventsFromPayload({ type: "custom_tool_call_output", name: "exec" }),
[]
);
assert.deepEqual(codex.eventsFromPayload({ type: "function_call", name: "web_search" }), []);
assert.deepEqual(codex.eventsFromPayload(null), []);
});
});
describe("codex ingest pass", () => {
describe("codex status mirror", () => {
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-"));
const DIR = path.join(HOME, "sessions", "2026", "08", "27");
let prevHome;
let seq = 0;
before(() => {
prevHome = process.env.CODEX_HOME;
@@ -117,21 +41,18 @@ describe("codex ingest pass", () => {
after(() => {
if (prevHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevHome;
codex.__resetOffsets();
try {
fs.rmSync(HOME, { recursive: true, force: true });
} catch {
/* best-effort */
}
codex.__resetCache();
fs.rmSync(HOME, { recursive: true, force: true });
});
/** A rollout journal for `cwd` with one `npm run test:server` shell call. */
function writeRollout(name, cwd) {
const file = path.join(DIR, name);
function writeRollout(cwd, { mtime } = {}) {
seq += 1;
const file = path.join(DIR, `rollout-2026-08-27T10-00-00-${seq}.jsonl`);
fs.writeFileSync(
file,
[
line({ cwd, session_id: "abc" }, "session_meta"),
line({ cwd, session_id: `s${seq}` }, "session_meta"),
line({
type: "function_call",
name: "exec_command",
@@ -139,256 +60,97 @@ describe("codex ingest pass", () => {
}),
].join("\n") + "\n"
);
if (mtime !== undefined) fs.utimesSync(file, mtime / 1000, mtime / 1000);
codex.__resetCache();
return file;
}
it("detects a stage for a codex lane and mirrors its status", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
writeRollout("rollout-2026-08-27T10-00-00-aaa.jsonl", cwd);
codex.__resetOffsets();
const codexLane = (prefix = "codex-lane-") =>
lanes.createLane({
title: "cx",
cwd: fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
tool: "codex",
});
it("marks a codex lane running while its journal is fresh, and broadcasts it", () => {
const lane = codexLane();
writeRollout(lane.cwd);
const seen = [];
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
const after = lanes.getLane(lane.id);
assert.equal(after.detected_stage, "tests"); // `npm run test:server` → default template's tests node
assert.match(after.detected_signal, /test:server/);
assert.equal(after.status, "running"); // a journal written just now means work is happening
assert.equal(lanes.getLane(lane.id).status, "running");
assert.deepEqual(seen, [lane.id]);
lanes.deleteLane(lane.id);
});
it("advances the declared stage from the detection, without evidence", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
writeRollout("rollout-2026-08-27T10-00-10-adv.jsonl", cwd);
codex.__resetOffsets();
it("never writes a stage or a detection, whatever the journal ran", () => {
const lane = codexLane();
writeRollout(lane.cwd);
codex.ingestCodexOnce({});
const after = lanes.getLane(lane.id);
// The ring moves — a codex session has nothing else that would move it…
assert.equal(after.stage, "tests");
// …but the node stays amber: an inference sees the tool call, not its result.
assert.equal(after.stages.tests.evidence, null);
// …and the badge survives, so the card still says WHY it moved.
assert.equal(after.detected_stage, "tests");
assert.match(after.detected_signal, /test:server/);
lanes.deleteLane(lane.id);
});
it("never drags an auto-advanced stage backwards", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.setStage(lane.id, { stage: "review", evidence: "declared by the session" });
writeRollout("rollout-2026-08-27T10-00-11-back.jsonl", cwd); // detects `tests`, behind `review`
codex.__resetOffsets();
codex.ingestCodexOnce({});
const after = lanes.getLane(lane.id);
assert.equal(after.stage, "review"); // declared-wins: recordDetection refused, so no advance
assert.equal(after.stage, "idle");
assert.equal(after.detected_stage, null);
assert.equal(after.stages.review.evidence, "declared by the session");
assert.equal(after.detected_signal, null);
lanes.deleteLane(lane.id);
});
it("a second pass with no appended bytes changes nothing", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
writeRollout("rollout-2026-08-27T10-00-01-bbb.jsonl", cwd);
codex.__resetOffsets();
it("a second pass with nothing new changes nothing", () => {
const lane = codexLane();
writeRollout(lane.cwd);
codex.ingestCodexOnce({});
const first = lanes.getLane(lane.id).detected_at;
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_at, first);
lanes.deleteLane(lane.id);
});
it("puts a running lane back to idle once its journal goes quiet", () => {
const lane = codexLane();
lanes.updateLane(lane.id, { status: "running" });
writeRollout(lane.cwd, { mtime: Date.now() - 6 * 60 * 60 * 1000 });
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).status, "idle");
lanes.deleteLane(lane.id);
});
it("treats a journal whose mtime is in the FUTURE as idle", () => {
// A clock stepped back (or a restore that preserved timestamps) leaves a
// finished thread's mtime ahead of now; raw `now - mtime` would read as live.
const lane = codexLane();
writeRollout(lane.cwd, { mtime: Date.now() + 6 * 60 * 60 * 1000 });
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).status, "idle");
lanes.deleteLane(lane.id);
});
it("never touches a claude lane, even at the journal's own cwd", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "claude-lane-"));
const lane = lanes.createLane({ title: "cc", cwd, tool: "claude" });
writeRollout("rollout-2026-08-27T10-00-02-ccc.jsonl", cwd);
codex.__resetOffsets();
writeRollout(cwd);
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
assert.equal(lanes.getLane(lane.id).status, "idle");
lanes.deleteLane(lane.id);
});
it("ignores a journal whose cwd belongs to no lane", () => {
const orphan = fs.mkdtempSync(path.join(os.tmpdir(), "codex-orphan-"));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
writeRollout("rollout-2026-08-27T10-00-03-ddd.jsonl", orphan);
codex.__resetOffsets();
const lane = codexLane();
writeRollout(fs.mkdtempSync(path.join(os.tmpdir(), "codex-orphan-")));
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
lanes.deleteLane(lane.id);
});
it("keeps byte offsets exact when a tail read starts mid multi-byte character", () => {
// The first sight of a >64 KiB journal reads only its tail, and that offset
// can land inside a 3-byte UTF-8 sequence. Splitting the decoded STRING and
// measuring it back with Buffer.byteLength then counts the decoder's U+FFFD
// replacement instead of the bytes actually consumed, so the next pass
// starts 1-2 bytes off and every later record is unparseable.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-2026-08-27T10-00-06-utf8.jsonl");
const record = (cmd) =>
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd, workdir: cwd }),
});
// Grow an all-multi-byte filler line until the tail boundary (size - 64 KiB)
// falls on a UTF-8 continuation byte.
const head = line({ cwd, session_id: "utf8" }, "session_meta") + "\n";
const tailRecord = record("npm run test:server") + "\n";
let filler = "→".repeat(30_000);
for (let pad = 0; pad < 8; pad += 1) {
const body = head + filler + "\n" + tailRecord;
const buf = Buffer.from(body, "utf8");
const boundary = buf.length - 64 * 1024;
if (boundary > 0 && (buf[boundary] & 0xc0) === 0x80) break;
filler += "x";
}
fs.writeFileSync(file, head + filler + "\n" + tailRecord);
const buf = Buffer.from(fs.readFileSync(file));
assert.equal(buf[buf.length - 64 * 1024] & 0xc0, 0x80, "fixture must straddle a UTF-8 char");
codex.__resetOffsets();
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
// The real proof: the next append must still parse, which it only does if
// the stored offset is exactly on the previous line boundary.
fs.appendFileSync(file, record("git push origin feat/x") + "\n");
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).detected_stage, "ship");
lanes.deleteLane(lane.id);
});
it("ingests a line appended exactly at the previous read's boundary", () => {
// The offset after a pass lands on a line boundary, so the next chunk's
// FIRST line is a whole record — dropping it as torn loses every append
// that arrives one line at a time, which is how a journal actually grows.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-2026-08-27T10-00-04-fff.jsonl");
fs.writeFileSync(file, line({ cwd, session_id: "boundary" }, "session_meta") + "\n");
codex.__resetOffsets();
codex.ingestCodexOnce({}); // consumes the header, offset now at EOF
assert.equal(lanes.getLane(lane.id).detected_stage, null);
fs.appendFileSync(
file,
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
}) + "\n"
);
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
lanes.deleteLane(lane.id);
});
it("re-reads a torn line whole on the next pass", () => {
// A pass that catches the writer mid-line must not consume that fragment:
// the record it belongs to would be lost forever.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-2026-08-27T10-00-05-ggg.jsonl");
const record = line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
});
fs.writeFileSync(
file,
line({ cwd, session_id: "torn" }, "session_meta") + "\n" + record.slice(0, 40)
);
codex.__resetOffsets();
codex.ingestCodexOnce({});
assert.equal(lanes.getLane(lane.id).detected_stage, null);
fs.appendFileSync(file, record.slice(40) + "\n"); // the writer finishes the line
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
lanes.deleteLane(lane.id);
});
it("does not replay a journal it sees for the first time already idle", () => {
// A thread that finished days ago must not paint a stage now — the first
// sight of a stale file only records where it ends.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = writeRollout("rollout-2026-08-20T10-00-00-eee.jsonl", cwd);
const old = Date.now() - 6 * 60 * 60 * 1000;
fs.utimesSync(file, old / 1000, old / 1000);
codex.__resetOffsets();
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
lanes.deleteLane(lane.id);
});
it("does not replay a journal whose mtime is in the FUTURE", () => {
// A clock stepped back (or a restore that preserved timestamps) leaves a
// finished thread's mtime ahead of now. Raw `now - mtime` is then negative,
// which reads as "written milliseconds ago" and would replay it as live work.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = writeRollout("rollout-2026-08-27T10-00-00-fut.jsonl", cwd);
const future = Date.now() + 6 * 60 * 60 * 1000;
fs.utimesSync(file, future / 1000, future / 1000);
codex.__resetOffsets();
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
lanes.deleteLane(lane.id);
});
it("ignores a torn/partial JSONL line at the end of a file", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-torn-line-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-torn.jsonl");
fs.writeFileSync(
file,
[
line({ cwd, session_id: "torn" }, "session_meta"),
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
}),
// A torn last line: not valid JSON, so it will be skipped on next pass
'{"timestamp":"2026-08-27T10:00:00.000Z","type":"response_item","paylo',
].join("\n")
);
codex.__resetOffsets();
const seen = [];
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
const after = lanes.getLane(lane.id);
assert.equal(after.detected_stage, "tests"); // the valid line was processed
assert.deepEqual(seen, [lane.id]);
assert.equal(lanes.getLane(lane.id).status, "idle");
lanes.deleteLane(lane.id);
});
it("handles a journal whose session_meta is unparseable gracefully", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-bad-meta-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-bad-meta.jsonl");
// Write an invalid JSON line as the first line
fs.writeFileSync(file, "not valid json at all\n");
codex.__resetOffsets();
const lane = codexLane();
fs.writeFileSync(path.join(DIR, "rollout-bad-meta.jsonl"), "not valid json at all\n");
codex.__resetCache();
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
assert.equal(lanes.getLane(lane.id).status, "idle");
lanes.deleteLane(lane.id);
});
@@ -397,50 +159,31 @@ describe("codex ingest pass", () => {
const inner = fs.mkdtempSync(path.join(outer, "inner"));
const outerLane = lanes.createLane({ title: "outer", cwd: outer, tool: "codex" });
const innerLane = lanes.createLane({ title: "inner", cwd: inner, tool: "codex" });
const file = path.join(DIR, "rollout-nested.jsonl");
fs.writeFileSync(
file,
[
line({ cwd: inner, session_id: "nested" }, "session_meta"),
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: inner }),
}),
].join("\n") + "\n"
);
codex.__resetOffsets();
writeRollout(inner);
const seen = [];
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
// Inner lane should be the one detected, not outer
assert.equal(lanes.getLane(innerLane.id).detected_stage, "tests");
assert.equal(lanes.getLane(outerLane.id).detected_stage, null);
assert.equal(lanes.getLane(innerLane.id).status, "running");
assert.equal(lanes.getLane(outerLane.id).status, "idle");
assert.deepEqual(seen, [innerLane.id]);
lanes.deleteLane(innerLane.id);
lanes.deleteLane(outerLane.id);
});
it("does not touch status of a lane with an active run_id", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-run-id-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const lane = codexLane();
lanes.updateLane(lane.id, { run_id: "active-run" });
writeRollout("rollout-2026-08-27T10-00-10-rrr.jsonl", cwd);
codex.__resetOffsets();
writeRollout(lane.cwd);
codex.ingestCodexOnce({});
const after = lanes.getLane(lane.id);
// Status should not change because run_id is present
assert.equal(after.status, "idle");
assert.equal(lanes.getLane(lane.id).status, "idle");
lanes.deleteLane(lane.id);
});
it("does not change status from provisioning or failed", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-status-protected-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const lane = codexLane();
lanes.updateLane(lane.id, { status: "provisioning" });
writeRollout("rollout-2026-08-27T10-00-11-ppp.jsonl", cwd);
codex.__resetOffsets();
writeRollout(lane.cwd);
codex.ingestCodexOnce({});
assert.equal(lanes.getLane(lane.id).status, "provisioning");
@@ -455,119 +198,13 @@ describe("codex ingest pass", () => {
const oldMs = process.env.DASHBOARD_CODEX_POLL_MS;
try {
process.env.DASHBOARD_CODEX_POLL_MS = "0";
// startCodexPoll should return early and not set up an interval
const timer = codex.startCodexPoll(() => {});
assert.equal(timer, undefined);
assert.equal(
codex.startCodexPoll(() => {}),
undefined
);
} finally {
if (oldMs === undefined) delete process.env.DASHBOARD_CODEX_POLL_MS;
else process.env.DASHBOARD_CODEX_POLL_MS = oldMs;
}
});
it("handles a shell call with command as an array in arguments", () => {
const events = codex.eventsFromPayload({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ command: ["npm", "run", "test:server"], workdir: "/repo" }),
});
// Should join array elements
assert.deepEqual(events, [
{ tool_name: "Bash", tool_input: { command: "npm run test:server" } },
]);
});
it("ignores a payload with a non-string name", () => {
// A payload with name as null or number should not match any tool
const events1 = codex.eventsFromPayload({
type: "function_call",
name: null,
arguments: "{}",
});
assert.deepEqual(events1, []);
const events2 = codex.eventsFromPayload({
type: "custom_tool_call",
name: 123,
input: "some script",
});
assert.deepEqual(events2, []);
});
it("uses the LAST detection in a batch to move the lane forward", () => {
// Within one ingest pass, if multiple tool calls map to different stages,
// recordDetection is called in order. Only the LAST one that actually writes
// should move the lane forward (forward-only). Simulate two tool calls:
// first impl call (implement), then tests call (tests).
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-batch-last-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = path.join(DIR, "rollout-batch-last.jsonl");
fs.writeFileSync(
file,
[
line({ cwd, session_id: "batch" }, "session_meta"),
// Edit event (implement node)
line({
type: "custom_tool_call",
name: "exec",
input: 'await tools.edit("src/main.js", "add x");',
}),
// Bash event (tests node)
line({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
}),
].join("\n") + "\n"
);
codex.__resetOffsets();
const seen = [];
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
const after = lanes.getLane(lane.id);
// Should detect the tests node (later in batch), not implement
assert.equal(after.detected_stage, "tests");
lanes.deleteLane(lane.id);
});
it("limits file scan to MAX_FILES newest files", () => {
// Create more than MAX_FILES journal files and verify only newest are scanned
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-max-files-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
// Create 50 old files (more than MAX_FILES=40)
const oldTime = Date.now() - 2 * 60 * 60 * 1000;
for (let i = 0; i < 50; i++) {
const file = path.join(DIR, `rollout-old-${String(i).padStart(2, "0")}.jsonl`);
fs.writeFileSync(file, line({ cwd, session_id: `old-${i}` }, "session_meta") + "\n");
fs.utimesSync(file, oldTime / 1000, oldTime / 1000);
}
codex.__resetOffsets();
// The scan should not stall and should return (no detection expected since all files are stale)
const result = codex.ingestCodexOnce({});
// All files are stale and not active, so no broadcast
assert.equal(result, 0);
lanes.deleteLane(lane.id);
});
it("detects correctly with Delete File in patch heredoc", () => {
const events = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input: 'const patch = "*** Begin Patch\\n*** Delete File: old/removed.js\\n*** End Patch";',
});
// Delete creates an Edit event (not Write, not ignored)
assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "old/removed.js" } }]);
});
it("handles file paths with whitespace in patch patches", () => {
const events = codex.eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input:
'const patch = "*** Begin Patch\\n*** Update File: src/my file.js \\n*** End Patch";',
});
// File path should be trimmed
assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "src/my file.js" } }]);
});
});
-142
View File
@@ -1,142 +0,0 @@
/**
* @file Unit tests for the codex evidence safety net: nudges a live codex pane
* to run `agenthub stage --evidence` when the lane has sat at an inferred (
* evidence-less) stage past the delay, without ever writing stage itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const SUITE_DB_PATH = path.join(
os.tmpdir(),
`dashboard-codex-nudge-${Date.now()}-${process.pid}.db`
);
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { db } = require("../db");
const lanes = require("../lib/lanes");
const tmux = require("../lib/tmux");
const nudge = require("../lib/codex-nudge");
/** Backdate the current stage's enteredAt so it looks `minutes` old. */
function backdateStage(laneId, minutes) {
const lane = lanes.getLane(laneId);
const stages = { ...lane.stages };
stages[lane.stage] = {
...stages[lane.stage],
enteredAt: new Date(Date.now() - minutes * 60_000).toISOString(),
};
db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId);
}
describe("codex-nudge sweep", () => {
let calls;
beforeEach(() => {
calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "codex"; // pane running the codex CLI
return "";
});
nudge.__resetNudgeState();
});
afterEach(() => {
tmux.__reset();
});
function makeCodexLane({ cwd, stage = "tests", evidence }) {
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
lanes.setStage(lane.id, { stage, evidence });
return lane;
}
it("nudges once the lane has held an evidence-less stage past the delay", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` });
backdateStage(lane.id, 3); // older than the 2 min delay
assert.equal(nudge.sweepCodexNudges(), 1);
const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l");
assert.equal(sendKeys.length, 1);
assert.match(sendKeys[0][4], /agenthub stage tests --tool codex --evidence/);
// A second pass for the same still-unevidenced stage must not repeat it.
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges before the delay has elapsed", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-b` });
// enteredAt is "now" — setStage just wrote it.
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a stage the session already backed with evidence", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c`, evidence: "12 tests green" });
backdateStage(lane.id, 10);
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a lane that has not entered a pipeline node", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-h`, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a claude lane", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" });
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
lanes.setStage(lane.id, { stage: "tests" });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a lane with no live run", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" });
lanes.setStage(lane.id, { stage: "tests" });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never types into a pane sitting at a bare shell prompt", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` });
backdateStage(lane.id, 3);
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "bash";
return "";
});
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("nudges again once the lane moves to another evidence-less stage", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1);
lanes.setStage(lane.id, { stage: "review" });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1);
lanes.deleteLane(lane.id);
});
});
+101
View File
@@ -661,6 +661,107 @@ describe("runGitAction — one hunk at a time", () => {
});
});
describe("runGitAction — one change block inside a hunk (issue #50)", () => {
beforeEach(freshFixture);
const base = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
/** Three change blocks close enough for git to merge into ONE hunk: lines
* 4-5 replaced, a line inserted after 8, line 11 deleted. */
function threeBlocks() {
write(LANE, "near.txt", base.join("\n") + "\n");
gc(LANE, "add", "near.txt");
gc(LANE, "commit", "-qm", "near");
const next = [...base];
next.splice(3, 2, "FOUR", "FIVE");
next.splice(8, 0, "INSERTED");
next.splice(11, 1); // "line 11"
write(LANE, "near.txt", next.join("\n") + "\n");
return next;
}
const hunkOf = async () => {
const [file] = (await collectChanges(LANE, { range: "work", file: "near.txt" })).files;
return file.hunks;
};
const read = () => fs.readFileSync(path.join(LANE, "near.txt"), "utf8").split("\n");
it("sees the three blocks as one git hunk (the shape the issue reports)", async () => {
threeBlocks();
assert.equal((await hunkOf()).length, 1);
});
it("stages ONE block, leaving the other two in the working tree", async () => {
threeBlocks();
const [hunk] = await hunkOf();
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 1 });
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
assert.match(staged, /^\+INSERTED$/m);
assert.doesNotMatch(staged, /FOUR|FIVE|^-line 11$/m);
const unstaged = g(LANE, "diff", "--", "near.txt");
assert.match(unstaged, /^\+FOUR$/m);
assert.match(unstaged, /^-line 11$/m);
assert.doesNotMatch(unstaged, /^\+INSERTED$/m); // staged: only context now
});
it("stages the LAST block after earlier ones shifted the line count", async () => {
threeBlocks();
const [hunk] = await hunkOf();
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 2 });
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
assert.match(staged, /^-line 11$/m);
assert.doesNotMatch(staged, /FOUR|INSERTED/);
});
it("undoes ONE block in the working tree, keeping the blocks around it", async () => {
const edited = threeBlocks();
const [hunk] = await hunkOf();
await runGitAction(LANE, "discard-hunk", {
path: "near.txt",
hunk: hunk.header,
block: 0,
confirm: true,
});
const want = [...edited];
want.splice(3, 2, "line 4", "line 5");
assert.deepEqual(read(), [...want, ""]);
});
it("splits a hunk whose last block only drops the final newline", async () => {
// One hunk: line 2 replaced, and line 6 losing its final newline — git
// writes that second block with a "\\ No newline" marker after its "+".
write(LANE, "tail.txt", "l1\nl2\nl3\nl4\nl5\nl6\n");
gc(LANE, "add", "tail.txt");
gc(LANE, "commit", "-qm", "tail");
write(LANE, "tail.txt", "l1\nTWO\nl3\nl4\nl5\nl6");
const [file] = (await collectChanges(LANE, { range: "work", file: "tail.txt" })).files;
assert.equal(file.hunks.length, 1);
const header = file.hunks[0].header;
await runGitAction(LANE, "stage-hunk", { path: "tail.txt", hunk: header, block: 1 });
const staged = g(LANE, "diff", "--cached", "--", "tail.txt");
assert.match(staged, /No newline at end of file/);
assert.doesNotMatch(staged, /TWO/);
await runGitAction(LANE, "discard-hunk", {
path: "tail.txt",
hunk: header,
block: 0,
confirm: true,
});
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
});
it("refuses a block the hunk does not have", async () => {
threeBlocks();
const [hunk] = await hunkOf();
for (const block of [3, -1, 1.5, "0"]) {
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block }),
{ code: "EHUNKGONE" }
);
}
});
});
describe("an unborn repo and `publish`", () => {
const EMPTY = path.join(ROOT, "empty");
const URL_OK = "https://example.test/owner/r.git";
+68 -20
View File
@@ -18,7 +18,7 @@ const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const lanesLib = require("../lib/lanes");
const { installAgents } = require("../lib/lane-agents");
const { installAgents, removeStageDoc } = require("../lib/lane-agents");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
@@ -116,29 +116,77 @@ describe("installAgents", () => {
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
});
it("installs the AGENTS.md stage block for a codex lane, not .claude/agents/", async () => {
it("installs nothing for a codex lane — no .claude/agents/, no AGENTS.md", async () => {
const cwd = makeRepoDir();
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
const result = await installAgents(lane);
assert.deepEqual(result.installed, ["AGENTS.md"]);
assert.deepEqual(result.installed, []);
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
const doc = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8");
assert.match(doc, /agenthub stage <stage> --tool codex/);
});
it("preserves the user's own AGENTS.md prose and replaces only the block", async () => {
const cwd = makeRepoDir();
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
const file = path.join(cwd, "AGENTS.md");
fs.writeFileSync(file, "# House rules\n\nNever force-push.\n");
await installAgents(lane);
await installAgents(lane); // reinstall must not duplicate the block
const doc = fs.readFileSync(file, "utf8");
assert.match(doc, /Never force-push\./);
assert.equal(doc.split("<!-- agenthub:stage:start -->").length - 1, 1);
assert.equal(doc.split("<!-- agenthub:stage:end -->").length - 1, 1);
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
});
});
describe("codex pipeline cleanup", () => {
const START = "<!-- agenthub:stage:start -->";
const END = "<!-- agenthub:stage:end -->";
const BLOCK = `${START}\n## Declare your lane stage as you work\n\nagenthub stage …\n${END}\n`;
it("removes only the AgentHub block, keeping the user's own AGENTS.md prose", () => {
const cwd = makeRepoDir();
const file = path.join(cwd, "AGENTS.md");
fs.writeFileSync(file, `# House rules\n\nNever force-push.\n\n${BLOCK}\n## More\n\nKeep me.\n`);
assert.equal(removeStageDoc(cwd), true);
const doc = fs.readFileSync(file, "utf8");
assert.ok(!doc.includes(START) && !doc.includes(END));
assert.match(doc, /Never force-push\./);
assert.match(doc, /Keep me\./);
});
it("deletes an AGENTS.md that held nothing but the block", () => {
const cwd = makeRepoDir();
fs.writeFileSync(path.join(cwd, "AGENTS.md"), BLOCK);
assert.equal(removeStageDoc(cwd), true);
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
});
it("leaves a file without the block, or no file at all, untouched", () => {
const cwd = makeRepoDir();
assert.equal(removeStageDoc(cwd), false);
fs.writeFileSync(path.join(cwd, "AGENTS.md"), "# Mine\n");
assert.equal(removeStageDoc(cwd), false);
assert.equal(fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8"), "# Mine\n");
});
it("wipes a codex lane's stage, detection, stage log and features — and no claude lane's", () => {
const codex = lanesLib.createLane({ title: "cx", cwd: makeRepoDir(), tool: "codex" });
const claude = lanesLib.createLane({ title: "cc", cwd: makeRepoDir(), tool: "claude" });
for (const lane of [codex, claude]) {
lanesLib.setStage(lane.id, { stage: "tests", evidence: "old" });
lanesLib.recordDetection(lane.id, { nodeId: "review", signal: "npm test" });
}
const { db } = require("../db");
const insertFeature = db.prepare("INSERT INTO lane_features (lane_id, slug) VALUES (?, ?)");
insertFeature.run(codex.id, "old-feature");
insertFeature.run(claude.id, "kept-feature");
assert.equal(lanesLib.clearCodexPipelines(), 1);
const cx = lanesLib.getLane(codex.id);
assert.equal(cx.stage, "idle");
assert.deepEqual(cx.stages, {});
assert.equal(cx.detected_stage, null);
const count = (table, id) =>
db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE lane_id = ?`).get(id).n;
assert.equal(count("lane_stage_events", codex.id), 0);
assert.equal(count("lane_features", codex.id), 0);
const cc = lanesLib.getLane(claude.id);
assert.equal(cc.stage, "tests");
assert.ok(count("lane_stage_events", claude.id) > 0);
assert.equal(count("lane_features", claude.id), 1);
assert.equal(lanesLib.clearCodexPipelines(), 0); // idempotent
});
});
+66 -9
View File
@@ -12,6 +12,7 @@ const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-mcp-"));
const FAKE_HOME = path.join(SUITE_ROOT, "home");
@@ -163,21 +164,77 @@ describe("syncMcp — profile seeding", () => {
});
});
describe("syncMcp — .git/info/exclude idempotency", () => {
it("appends .mcp.json once and does not duplicate it on a second call", async () => {
describe("syncMcp — keeping .mcp.json out of git", () => {
// Real repos, asserted through `git status`: git reads info/exclude from the
// COMMON git dir only, which a fake `.git` dir cannot show.
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
// would point these fixture calls at the outer repo.
const git = (cwd, ...args) => {
const env = { ...process.env };
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
delete env[name];
}
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
};
const untracked = (cwd) => git(cwd, "status", "--porcelain").split("\n").filter(Boolean);
function mcpLane(cwd) {
const sourceRepo = path.join(SUITE_ROOT, "src-exclude");
fs.mkdirSync(sourceRepo, { recursive: true });
writeClaudeJson({
[sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
});
const lane = makeLane(sourceRepo);
fs.mkdirSync(path.join(lane.cwd, ".git"), { recursive: true });
return { cwd, source_repo: sourceRepo };
}
await laneMcp.syncMcp(lane);
await laneMcp.syncMcp(lane);
function makeRepo() {
const repo = makeLane(null).cwd;
git(repo, "init", "-q");
git(
repo,
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init"
);
return repo;
}
const exclude = fs.readFileSync(path.join(lane.cwd, ".git", "info", "exclude"), "utf8");
const matches = exclude.split("\n").filter((line) => line === ".mcp.json");
assert.equal(matches.length, 1);
it("hides .mcp.json, once, however many times it syncs", async () => {
const repo = makeRepo();
await laneMcp.syncMcp(mcpLane(repo));
await laneMcp.syncMcp(mcpLane(repo));
assert.deepEqual(untracked(repo), []);
const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
assert.equal(exclude.split("\n").filter((line) => line.includes(".mcp.json")).length, 1);
});
it("hides it in a worktree lane, whose .git is a pointer file", async () => {
const repo = makeRepo();
const wt = path.join(SUITE_ROOT, `wt-${laneSeq}`);
git(repo, "worktree", "add", "-q", "-b", "lane1", wt);
await laneMcp.syncMcp(mcpLane(wt));
assert.deepEqual(untracked(wt), []);
});
it("hides it in a lane that is a subdirectory of its repo", async () => {
const repo = makeRepo();
const sub = path.join(repo, "packages", "app");
fs.mkdirSync(sub, { recursive: true });
await laneMcp.syncMcp(mcpLane(sub));
assert.deepEqual(untracked(repo), []);
});
it("creates no .git in a lane that is not a repo", async () => {
const cwd = makeLane(null).cwd;
await laneMcp.syncMcp(mcpLane(cwd));
assert.ok(fs.existsSync(path.join(cwd, ".mcp.json")));
assert.equal(fs.existsSync(path.join(cwd, ".git")), false);
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* @file Tests scripts/launchd-service.js, the macOS LaunchAgent twin of the
* systemd unit: the plist it writes (KeepAlive, AbandonProcessGroup so lane
* panes survive a restart, the plugin root as WorkingDirectory), that writing
* starts nothing, that a restart re-reads the plist (bootout + bootstrap, not
* kickstart), and that a foreign plist is never touched. `launchctl` is
* stubbed and the platform faked, so this runs on any OS.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-launchd-"));
const prevHome = process.env.HOME;
process.env.HOME = TMP_HOME;
const realPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin" });
const svc = require("../../scripts/launchd-service");
const DOMAIN = `gui/${process.getuid()}`;
const JOB = `${DOMAIN}/io.smartgift.agenthub`;
let calls = [];
let loaded = false;
let running = false;
let failBootstrap = false;
let noGui = false;
beforeEach(() => {
calls = [];
loaded = running = failBootstrap = noGui = false;
fs.rmSync(svc.unitPath("agenthub"), { force: true });
svc.__setExecImpl((args) => {
if (args[0] === "print" && args[1] === DOMAIN) return { status: noGui ? 113 : 0 };
calls.push(args.join(" "));
if (args[0] === "print") {
return {
status: loaded ? 0 : 113,
stdout: running ? "\tstate = running\n" : "\tstate = not running\n",
};
}
if (args[0] === "bootstrap") return { status: failBootstrap ? 5 : 0 };
return { status: 0 };
});
});
after(() => {
svc.__reset();
Object.defineProperty(process, "platform", realPlatform);
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
const UNIT = {
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
workingDir: "/plugin/root/new & improved",
execStart: "/usr/local/bin/node /plugin/root/new/server/index.js",
argv: ["/usr/local/bin/node", "/plugin/root/new & improved/server/index.js"],
env: { NODE_ENV: "production", PATH: "/usr/bin:/bin" },
};
const plist = () => fs.readFileSync(svc.unitPath("agenthub"), "utf8");
describe("launchd-service — writeUserService", () => {
it("writes a LaunchAgent that restarts forever and spares the lane panes, and starts nothing", () => {
assert.equal(svc.writeUserService(UNIT), "written");
const body = plist();
assert.ok(svc.unitPath("agenthub").startsWith(path.join(TMP_HOME, "Library", "LaunchAgents")));
assert.match(body, /<key>Label<\/key>\s*<string>io\.smartgift\.agenthub<\/string>/);
assert.match(body, /<key>KeepAlive<\/key>\s*<true\/>/);
assert.match(body, /<key>RunAtLoad<\/key>\s*<true\/>/);
assert.match(body, /<key>AbandonProcessGroup<\/key>\s*<true\/>/);
// argv stays split, so a path with a space is one argument; XML escaped.
assert.match(body, /<string>\/plugin\/root\/new &amp; improved\/server\/index\.js<\/string>/);
assert.match(body, /<key>PATH<\/key>\s*<string>\/usr\/bin:\/bin<\/string>/);
assert.deepEqual(calls, [`enable ${JOB}`], "writing must not load or start the job");
});
it("leaves an identical plist alone", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.writeUserService(UNIT), "unchanged");
assert.deepEqual(calls, []);
});
it("never overwrites a plist it did not write", () => {
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(svc.unitPath("agenthub"), "<plist>theirs</plist>");
assert.equal(svc.writeUserService(UNIT), "foreign");
assert.equal(plist(), "<plist>theirs</plist>");
assert.equal(svc.uninstallUserService("agenthub"), "foreign");
assert.ok(fs.existsSync(svc.unitPath("agenthub")));
});
it("is unavailable without a GUI login, and writes nothing", () => {
noGui = true;
assert.equal(svc.systemctlAvailable(), false);
assert.equal(svc.writeUserService(UNIT), "unavailable");
assert.ok(!fs.existsSync(svc.unitPath("agenthub")));
});
});
describe("launchd-service — stale and root", () => {
it("reads the working directory back, escaped path included", () => {
svc.writeUserService(UNIT);
assert.equal(svc.unitRoot("agenthub"), UNIT.workingDir);
assert.equal(svc.unitIsStale("agenthub", UNIT.workingDir), false);
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/newer"), true);
});
it("calls neither a missing nor a foreign plist stale", () => {
assert.equal(svc.unitIsStale("agenthub", "/x"), false);
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(
svc.unitPath("agenthub"),
"<key>WorkingDirectory</key><string>/elsewhere</string>"
);
assert.equal(svc.unitIsStale("agenthub", "/x"), false);
assert.equal(svc.unitRoot("agenthub"), "/elsewhere", "the pruner still must not delete it");
});
});
describe("launchd-service — restart, install, uninstall", () => {
it("restarts by bootout + bootstrap, so the new plist is what runs", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.restartUserService("agenthub"), "restarted");
assert.deepEqual(calls, [`bootout ${JOB}`, `bootstrap ${DOMAIN} ${svc.unitPath("agenthub")}`]);
});
it("reports a failed bootstrap, so the caller can fall back", () => {
failBootstrap = true;
assert.equal(svc.restartUserService("agenthub"), "failed");
});
it("install loads a new plist, and only kickstarts one already loaded", () => {
assert.equal(svc.installUserService(UNIT), "written");
assert.ok(calls.includes(`bootstrap ${DOMAIN} ${svc.unitPath("agenthub")}`));
calls = [];
loaded = true;
assert.equal(svc.installUserService(UNIT), "unchanged");
assert.ok(calls.includes(`kickstart ${JOB}`));
assert.ok(!calls.some((c) => c.startsWith("bootout")), "a running dashboard is not bounced");
});
it("uninstall unloads and deletes our plist", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.uninstallUserService("agenthub"), "removed");
assert.deepEqual(calls, [`bootout ${JOB}`]);
assert.ok(!fs.existsSync(svc.unitPath("agenthub")));
assert.equal(svc.uninstallUserService("agenthub"), "absent");
});
it("is active only when launchd says the job is running", () => {
assert.equal(svc.unitIsActive("agenthub"), false);
loaded = true;
assert.equal(svc.unitIsActive("agenthub"), false);
running = true;
assert.equal(svc.unitIsActive("agenthub"), true);
});
});
describe("service-manager", () => {
it("hands out the LaunchAgent on macOS", () => {
delete require.cache[require.resolve("../../scripts/service-manager")];
assert.equal(require("../../scripts/service-manager").kind, "launchd");
});
});
+64 -29
View File
@@ -8,6 +8,7 @@
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
@@ -144,50 +145,84 @@ describe("router-apply — Claude lane", () => {
});
describe("router-apply — the written file must survive a lane reset", () => {
it("excludes settings.local.json from git, so `git clean -fd` cannot delete it", () => {
// Real repos, asserted through `git status`: a fake `.git` dir let a test pass
// while git itself never read the exclude file it checked.
const REPO = path.join(SUITE_ROOT, "repo");
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
// would point these fixture calls at the outer repo.
const git = (cwd, ...args) => {
const env = { ...process.env };
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
delete env[name];
}
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
};
const untracked = (cwd) =>
git(cwd, "status", "--porcelain", "--untracked-files=all", "--ignored=no")
.split("\n")
.filter(Boolean);
// A machine-wide ignore (Claude Code adds one for settings.local.json) would
// hide the very file these tests look for.
beforeEach(() => {
process.env.GIT_CONFIG_GLOBAL = "/dev/null";
process.env.GIT_CONFIG_NOSYSTEM = "1";
fs.rmSync(REPO, { recursive: true, force: true });
fs.mkdirSync(REPO, { recursive: true });
git(REPO, "init", "-q");
git(
REPO,
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init"
);
});
const laneAt = (cwd) => ({ id: 7, cwd, tool: "claude" });
it("hides settings.local.json and its backups, so `git clean -fd` cannot delete them", () => {
// `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js).
// Without -x that spares IGNORED files only — an untracked settings file
// would be deleted, silently detaching the lane from its router. The same
// entry also stops an API key from being committable.
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
applyRouterToLane(laneAt(REPO), name);
applyRouterToLane(laneAt(REPO), "native"); // a second write leaves a backup
assert.deepEqual(untracked(REPO), []);
});
it("excludes it for a native lane too, not only a lane on a router", () => {
// The blanked connection pair is load-bearing in its own right: losing it
// to `git clean -fd` lets a USER-level router take over a lane that
// explicitly asked for native.
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
applyRouterToLane(claudeLane(), "native");
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
it("hides them in a worktree lane, whose exclude file is the COMMON dir's", () => {
// git reads info/exclude from the common git dir only; a copy under
// .git/worktrees/<name>/info/ is never consulted.
const wt = path.join(SUITE_ROOT, "wt");
fs.rmSync(wt, { recursive: true, force: true });
git(REPO, "worktree", "add", "-q", "-b", "lane1", wt);
const { name } = saveNineRouter();
applyRouterToLane(laneAt(wt), name);
applyRouterToLane(laneAt(wt), "native");
assert.deepEqual(untracked(wt), []);
});
it("resolves the real git dir when .git is a file, as it is in every worktree lane", () => {
// A linked worktree's `.git` is a pointer file; joining ".git/info" onto it
// would be a path that cannot exist.
const gitDir = path.join(SUITE_ROOT, "realgit", "worktrees", "lane1");
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(LANE_CWD, ".git"), `gitdir: ${gitDir}\n`);
it("hides them in a lane that is a subdirectory of its repo", () => {
const sub = path.join(REPO, "packages", "app");
fs.mkdirSync(sub, { recursive: true });
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
const exclude = fs.readFileSync(path.join(gitDir, "info", "exclude"), "utf8");
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
applyRouterToLane(laneAt(sub), name);
applyRouterToLane(laneAt(sub), "native");
assert.deepEqual(untracked(REPO), []);
});
it("adds the entry once, however many times a router is applied", () => {
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
applyRouterToLane(claudeLane(), "native");
applyRouterToLane(laneAt(REPO), name);
applyRouterToLane(laneAt(REPO), "native");
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
const exclude = fs.readFileSync(path.join(REPO, ".git", "info", "exclude"), "utf8");
assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1);
});