feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file Regression tests for scripts/hook-handler.js delivery behavior. The
|
||||
* handler must never block Claude Code waiting for the dashboard's HTTP
|
||||
* response — it delivers the event (flushes the request) and exits, leaving the
|
||||
* local server to process the buffered request on its own schedule. These tests
|
||||
* lock in that non-blocking contract so a future refactor can't reintroduce the
|
||||
* "stuck running hooks" stall (handler waiting up to the per-request timeout for
|
||||
* a slow/busy/wedged dashboard to reply).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
const HANDLER = path.resolve(__dirname, "../../scripts/hook-handler.js");
|
||||
|
||||
// A mock dashboard that fully RECEIVES the request (records the body) but can be
|
||||
// told to delay its HTTP response — emulating a busy/slow/wedged server.
|
||||
function startMockServer({ responseDelayMs }) {
|
||||
const received = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
received.push(body);
|
||||
const reply = () => {
|
||||
try {
|
||||
res.end('{"ok":true}');
|
||||
} catch {
|
||||
/* client already gone — expected when the handler exits early */
|
||||
}
|
||||
};
|
||||
if (responseDelayMs > 0) setTimeout(reply, responseDelayMs);
|
||||
else reply();
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve({ server, port: server.address().port, received });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn the real handler, pipe a hook payload to stdin, and time how long it
|
||||
// takes to exit.
|
||||
function runHandler({ port, hookType = "Stop", payload }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = process.hrtime.bigint();
|
||||
const child = spawn(process.execPath, [HANDLER, hookType], {
|
||||
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(port) },
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
resolve({ code, ms: Number(process.hrtime.bigint() - start) / 1e6 });
|
||||
});
|
||||
child.stdin.write(JSON.stringify(payload));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe("hook-handler non-blocking delivery", () => {
|
||||
it("exits without waiting for a slow dashboard response, yet still delivers the event", async () => {
|
||||
// Server takes 5s to respond — far longer than the handler's own safety net.
|
||||
const { server, port, received } = await startMockServer({ responseDelayMs: 5000 });
|
||||
try {
|
||||
const { code, ms } = await runHandler({
|
||||
port,
|
||||
payload: { session_id: "hh-slow", stop_reason: "end_turn" },
|
||||
});
|
||||
|
||||
assert.equal(code, 0, "handler should exit cleanly");
|
||||
// Must NOT have waited on the 5s response (and must beat its 2.5s safety
|
||||
// net): a healthy deliver-and-exit is well under a second.
|
||||
assert.ok(
|
||||
ms < 2000,
|
||||
`handler should exit fast (was ${ms.toFixed(0)}ms) despite the 5s server response`
|
||||
);
|
||||
|
||||
// Delivery is preserved even though we exited before the reply.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
assert.equal(received.length, 1, "event should be delivered exactly once");
|
||||
assert.match(received[0], /hh-slow/, "delivered payload should carry the session id");
|
||||
assert.match(received[0], /"hook_type":"Stop"/, "payload should be wrapped with hook_type");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("exits promptly when no dashboard is listening (connection refused)", async () => {
|
||||
// Grab a port then close it so nothing is listening there.
|
||||
const { server, port } = await startMockServer({ responseDelayMs: 0 });
|
||||
await new Promise((r) => server.close(r));
|
||||
|
||||
const { code, ms } = await runHandler({
|
||||
port,
|
||||
payload: { session_id: "hh-dead", stop_reason: "end_turn" },
|
||||
});
|
||||
|
||||
assert.equal(code, 0, "handler should still exit cleanly with no listener");
|
||||
assert.ok(
|
||||
ms < 2000,
|
||||
`handler should exit fast on a refused connection (was ${ms.toFixed(0)}ms)`
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user