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:
2026-07-29 17:07:45 +07:00
commit 62425b2f58
781 changed files with 220565 additions and 0 deletions
+1612
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,127 @@
/**
* @file Verifies the legacy `agents_new` rebuild in db.js (triggered when the
* agents table's stored CHECK constraint still contains the old 'idle'
* status) preserves the workflow_run_id/workflow_phase columns added by the
* earlier workflow migration, plus awaiting_reason, and recreates
* idx_agents_workflow.
*
* Regression coverage for a bug flagged on PR #228: the rebuild's CREATE
* TABLE agents_new / INSERT INTO agents_new SELECT / index-recreate block was
* written before the workflow_run_id + workflow_phase columns existed, so it
* silently dropped them (and idx_agents_workflow) for any DB old enough to
* still carry the legacy CHECK. Because the workflow_run_id/workflow_phase
* prepared statements are compiled at module load time, right after the
* migrations run, this crashed startup for such legacy DBs.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const Database = require("better-sqlite3");
let TEST_DB;
before(() => {
TEST_DB = path.join(os.tmpdir(), `dashboard-agents-legacy-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
// Hand-build a pre-workflow, pre-awaiting-reason legacy DB: a sessions table
// just rich enough to satisfy the agents FK, and an agents table using the
// old 3/5-status CHECK (still containing 'idle') with none of the columns
// added by later migrations (workflow_run_id, workflow_phase, updated_at,
// awaiting_input_since, awaiting_reason). This is the exact shape the
// 'idle' CHECK detector at db.js:~731 is looking for.
const raw = new Database(TEST_DB);
raw.pragma("foreign_keys = OFF");
raw.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
name TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','completed','error','abandoned')),
cwd TEXT,
model TEXT,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
ended_at TEXT,
metadata TEXT
);
CREATE TABLE agents (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'main' CHECK(type IN ('main','subagent')),
subagent_type TEXT,
status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle','connected','working','completed','error')),
task TEXT,
current_tool TEXT,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
ended_at TEXT,
parent_agent_id TEXT,
metadata TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (parent_agent_id) REFERENCES agents(id) ON DELETE SET NULL
);
`);
raw
.prepare("INSERT INTO sessions (id, name, status, cwd, model) VALUES (?, ?, ?, ?, ?)")
.run("s-legacy-1", "legacy session", "active", "/tmp/legacy-proj", "claude");
raw
.prepare(
"INSERT INTO agents (id, session_id, name, type, status, task) VALUES (?, ?, ?, ?, ?, ?)"
)
.run("a-legacy-1", "s-legacy-1", "legacy main agent", "main", "idle", "legacy task");
raw.close();
});
after(() => {
try {
delete require.cache[require.resolve("../db")];
} catch {}
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {}
}
});
describe("legacy agents_new rebuild (pre-workflow, pre-awaiting-reason DB)", () => {
it("loads db.js against the legacy DB without throwing", () => {
delete require.cache[require.resolve("../db")];
assert.doesNotThrow(() => require("../db"));
});
it("adds workflow_run_id, workflow_phase and awaiting_reason to agents", () => {
const { db } = require("../db");
const cols = db.prepare("PRAGMA table_info(agents)").all();
const names = cols.map((c) => c.name);
for (const col of ["workflow_run_id", "workflow_phase", "awaiting_reason"]) {
assert.ok(names.includes(col), `expected agents.${col}; got: ${names.join(",")}`);
}
});
it("recreates idx_agents_workflow", () => {
const { db } = require("../db");
const idx = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_agents_workflow'"
)
.get();
assert.ok(idx, "expected idx_agents_workflow to exist after the rebuild");
});
it("preserves the pre-existing agent row and remaps its status idle -> waiting", () => {
const { db } = require("../db");
const row = db.prepare("SELECT * FROM agents WHERE id = ?").get("a-legacy-1");
assert.ok(row, "expected the legacy agent row to survive the rebuild");
assert.equal(row.status, "waiting");
assert.equal(row.session_id, "s-legacy-1");
assert.equal(row.name, "legacy main agent");
assert.equal(row.task, "legacy task");
assert.equal(row.workflow_run_id, null);
assert.equal(row.workflow_phase, null);
});
});
+398
View File
@@ -0,0 +1,398 @@
/**
* @file Tests for the rules-based alerting engine: rule CRUD validation,
* event-driven evaluation on hook ingest (pattern match, count-in-window
* threshold, token threshold), cooldown dedup, the time-based sweep
* (inactivity, stuck-agent status duration), and acknowledge endpoints.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
// Set up test database BEFORE requiring any server modules
const TEST_DB = path.join(os.tmpdir(), `dashboard-alerts-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const { sweepTimeRules } = require("../lib/alerts");
let server;
let BASE;
function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed, headers: res.headers });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
function post(urlPath, body) {
return fetch(urlPath, { method: "POST", body });
}
function patch(urlPath, body) {
return fetch(urlPath, { method: "PATCH", body });
}
function del(urlPath) {
return fetch(urlPath, { method: "DELETE" });
}
function postHook(hookType, data) {
return post("/api/hooks/event", { hook_type: hookType, data });
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
const addr = server.address();
BASE = `http://127.0.0.1:${addr.port}`;
});
after(() => {
server?.close();
try {
db.close();
} catch {
/* already closed */
}
});
describe("Alert rule CRUD", () => {
it("rejects a rule without a name", async () => {
const res = await post("/api/alerts/rules", {
rule_type: "inactivity",
config: { minutes: 5 },
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
});
it("rejects an unknown rule_type", async () => {
const res = await post("/api/alerts/rules", {
name: "bad",
rule_type: "nope",
config: {},
});
assert.equal(res.status, 400);
});
it("rejects event_pattern without any pattern field", async () => {
const res = await post("/api/alerts/rules", {
name: "bad pattern",
rule_type: "event_pattern",
config: { count: 2 },
});
assert.equal(res.status, 400);
assert.match(res.body.error.message, /at least one/);
});
it("rejects invalid type-specific config values", async () => {
const badMinutes = await post("/api/alerts/rules", {
name: "bad minutes",
rule_type: "inactivity",
config: { minutes: -3 },
});
assert.equal(badMinutes.status, 400);
const badStatus = await post("/api/alerts/rules", {
name: "bad status",
rule_type: "status_duration",
config: { status: "completed", minutes: 5 },
});
assert.equal(badStatus.status, 400);
const badTokens = await post("/api/alerts/rules", {
name: "bad tokens",
rule_type: "token_threshold",
config: { total_tokens: 0 },
});
assert.equal(badTokens.status, 400);
});
it("creates, lists, updates, and deletes a rule", async () => {
const created = await post("/api/alerts/rules", {
name: "CRUD rule",
rule_type: "inactivity",
config: { minutes: 30 },
});
assert.equal(created.status, 201);
const rule = created.body.rule;
assert.ok(rule.id);
assert.equal(rule.enabled, true);
assert.equal(rule.cooldown_seconds, 300);
assert.deepEqual(rule.config, { minutes: 30 });
const list = await fetch("/api/alerts/rules");
assert.equal(list.status, 200);
assert.ok(list.body.rules.some((r) => r.id === rule.id));
const updated = await patch(`/api/alerts/rules/${rule.id}`, {
name: "CRUD rule v2",
enabled: false,
cooldown_seconds: 60,
});
assert.equal(updated.status, 200);
assert.equal(updated.body.rule.name, "CRUD rule v2");
assert.equal(updated.body.rule.enabled, false);
assert.equal(updated.body.rule.cooldown_seconds, 60);
const badPatch = await patch(`/api/alerts/rules/${rule.id}`, {
config: { minutes: "soon" },
});
assert.equal(badPatch.status, 400);
const deleted = await del(`/api/alerts/rules/${rule.id}`);
assert.equal(deleted.status, 200);
const again = await del(`/api/alerts/rules/${rule.id}`);
assert.equal(again.status, 404);
});
});
describe("Event-driven alert evaluation", () => {
it("fires on a matching event and dedups within cooldown", async () => {
const created = await post("/api/alerts/rules", {
name: "Bash watcher",
rule_type: "event_pattern",
config: { tool_name: "Bash" },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-pattern-${Date.now()}`;
await postHook("PreToolUse", { session_id: sessionId, tool_name: "Bash" });
let feed = await fetch("/api/alerts");
let fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.equal(fired[0].session_id, sessionId);
assert.equal(fired[0].rule_name, "Bash watcher");
assert.equal(fired[0].acknowledged_at, null);
// Second matching event inside the 300s default cooldown — no new alert.
await postHook("PreToolUse", { session_id: sessionId, tool_name: "Bash" });
feed = await fetch("/api/alerts");
fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
await del(`/api/alerts/rules/${ruleId}`);
});
it("only fires a count threshold once N events land in the window", async () => {
const created = await post("/api/alerts/rules", {
name: "Error burst",
rule_type: "event_pattern",
config: { event_type: "BurstProbe", count: 3, window_minutes: 5 },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-burst-${Date.now()}`;
await postHook("BurstProbe", { session_id: sessionId });
await postHook("BurstProbe", { session_id: sessionId });
let feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
await postHook("BurstProbe", { session_id: sessionId });
feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.match(fired[0].message, /3 matching events/);
await del(`/api/alerts/rules/${ruleId}`);
});
it("fires when session token usage crosses the threshold", async () => {
const created = await post("/api/alerts/rules", {
name: "Token spike",
rule_type: "token_threshold",
config: { total_tokens: 1000000 },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-tokens-${Date.now()}`;
// Seed the session, then push usage past the threshold directly.
await postHook("SessionStart", { session_id: sessionId });
stmts.upsertTokenUsage.run(sessionId, "claude-test-model", 700000, 600000, 0, 0);
// PostToolUse is a token-bearing event type, so evaluation runs.
await postHook("PostToolUse", { session_id: sessionId, tool_name: "Read" });
const feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.equal(fired[0].session_id, sessionId);
await del(`/api/alerts/rules/${ruleId}`);
});
it("does not fire disabled rules", async () => {
const created = await post("/api/alerts/rules", {
name: "Disabled watcher",
rule_type: "event_pattern",
config: { tool_name: "Grep" },
enabled: false,
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-disabled-${Date.now()}`;
await postHook("PreToolUse", { session_id: sessionId, tool_name: "Grep" });
const feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
await del(`/api/alerts/rules/${ruleId}`);
});
});
describe("Time-based alert sweep", () => {
it("fires inactivity alerts for stale active sessions", async () => {
const created = await post("/api/alerts/rules", {
name: "Idle session",
rule_type: "inactivity",
config: { minutes: 30 },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-idle-${Date.now()}`;
await postHook("SessionStart", { session_id: sessionId });
// Backdate the session's last activity to one hour ago.
db.prepare(
"UPDATE sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-60 minutes') WHERE id = ?"
).run(sessionId);
sweepTimeRules();
const feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.equal(fired[0].session_id, sessionId);
// Re-sweeping inside the cooldown must not duplicate the alert.
sweepTimeRules();
const again = await fetch("/api/alerts");
assert.equal(again.body.alerts.filter((a) => a.rule_id === ruleId).length, 1);
await del(`/api/alerts/rules/${ruleId}`);
});
it("fires status_duration alerts for stuck agents", async () => {
const created = await post("/api/alerts/rules", {
name: "Stuck agent",
rule_type: "status_duration",
config: { status: "working", minutes: 10 },
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionId = `alerts-stuck-${Date.now()}`;
await postHook("SessionStart", { session_id: sessionId });
// ensureSession created `<id>-main` as working; backdate its activity.
db.prepare(
"UPDATE agents SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') WHERE id = ?"
).run(`${sessionId}-main`);
sweepTimeRules();
const feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
assert.equal(fired[0].agent_id, `${sessionId}-main`);
assert.equal(fired[0].session_id, sessionId);
await del(`/api/alerts/rules/${ruleId}`);
});
});
describe("Alert feed and acknowledgement", () => {
it("acks a single alert, filters unacked, and acks all", async () => {
const created = await post("/api/alerts/rules", {
name: "Ack target",
rule_type: "event_pattern",
config: { event_type: "AckProbe" },
cooldown_seconds: 0,
});
assert.equal(created.status, 201);
const ruleId = created.body.rule.id;
const sessionA = `alerts-ack-a-${Date.now()}`;
const sessionB = `alerts-ack-b-${Date.now()}`;
await postHook("AckProbe", { session_id: sessionA });
await postHook("AckProbe", { session_id: sessionB });
let feed = await fetch("/api/alerts?unacked=true");
const mine = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(mine.length, 2);
const ackOne = await post(`/api/alerts/${mine[0].id}/ack`);
assert.equal(ackOne.status, 200);
assert.ok(ackOne.body.alert.acknowledged_at);
feed = await fetch("/api/alerts?unacked=true");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 1);
const missing = await post("/api/alerts/999999/ack");
assert.equal(missing.status, 404);
const ackAll = await post("/api/alerts/ack-all");
assert.equal(ackAll.status, 200);
assert.ok(ackAll.body.acknowledged >= 1);
feed = await fetch("/api/alerts?unacked=true");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
assert.equal(feed.body.unacked, 0);
await del(`/api/alerts/rules/${ruleId}`);
});
it("deleting a rule cascades its alert history away", async () => {
const created = await post("/api/alerts/rules", {
name: "Cascade check",
rule_type: "event_pattern",
config: { event_type: "CascadeProbe" },
});
const ruleId = created.body.rule.id;
const sessionId = `alerts-cascade-${Date.now()}`;
await postHook("CascadeProbe", { session_id: sessionId });
let feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 1);
await del(`/api/alerts/rules/${ruleId}`);
feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
/**
* @file Regression: a BACKGROUND subagent's tool events must not clear a
* genuine 'notification' waiting flag held by the MAIN agent (blocked on the
* user via AskUserQuestion / permission). Before the fix, PreToolUse/PostToolUse
* cleared awaiting_input_since unconditionally, so a session that was truly
* "waiting for you" oscillated back to active on every subagent tool event —
* AI-Deck (12s poll) and deck-web (5s WS) then disagreed about the same session.
*
* The guard reuses the existing subagent-actor heuristic (findDeepestWorkingAgent
* while main is 'waiting'): when a subagent is the actor, only PASSIVE waits
* (stop/session_start/interrupted) are cleared; a 'notification' wait is
* preserved. When MAIN is the actor, clearing is unconditional (keeps the
* documented permission-mid-tool path intact).
*
* This test lives in the fork's own suite so a future upstream merge that
* silently reverts the guard fails loudly here (home-network monitor fork,
* see decisions/).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `awaiting-guard-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
},
(res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const post = (p, body) => fetch(p, { method: "POST", body });
const hook = (hook_type, data) => post("/api/hooks/event", { hook_type, data });
const sessionOf = async (id) => (await fetch(`/api/sessions/${id}`)).body.session;
/**
* Drive a session into: main agent 'waiting' with the given reason, AND a live
* working subagent (so findDeepestWorkingAgent returns it). Returns nothing;
* asserts each precondition so a harness regression is obvious.
*/
async function sessionWaitingWithWorkingSubagent(sid, { notification }) {
await hook("SessionStart", { session_id: sid });
// UserPromptSubmit clears the session_start wait and promotes main → working.
await hook("UserPromptSubmit", { session_id: sid, prompt: "go" });
// Main (working) spawns a subagent → subagent row inserted with status working.
await hook("PreToolUse", {
session_id: sid,
tool_name: "Agent",
tool_input: { subagent_type: "reviewer", prompt: "review" },
});
// Now block the MAIN agent. A waiting-for-user Notification stamps
// reason='notification'; a Stop stamps the passive reason='stop'.
if (notification) {
await hook("Notification", { session_id: sid, message: "Claude is waiting for your input" });
} else {
await hook("Stop", { session_id: sid });
}
const sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since, "precondition: session should be awaiting");
assert.equal(
sess.awaiting_reason,
notification ? "notification" : "stop",
"precondition: expected awaiting_reason"
);
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
});
describe("awaiting guard: subagent tool events vs. main-agent waiting", () => {
it("PRESERVES a 'notification' wait when a background subagent fires PreToolUse", async () => {
const sid = "guard-notif-pre";
await sessionWaitingWithWorkingSubagent(sid, { notification: true });
// Subagent (deepest working, main is waiting) runs a tool. This must NOT
// clear the main agent's genuine "waiting for you" flag.
await hook("PreToolUse", { session_id: sid, tool_name: "Bash" });
const sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since, "notification wait must survive subagent PreToolUse");
assert.equal(sess.awaiting_reason, "notification");
});
it("PRESERVES a 'notification' wait when a background subagent fires PostToolUse", async () => {
const sid = "guard-notif-post";
await sessionWaitingWithWorkingSubagent(sid, { notification: true });
await hook("PostToolUse", { session_id: sid, tool_name: "Bash" });
const sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since, "notification wait must survive subagent PostToolUse");
assert.equal(sess.awaiting_reason, "notification");
});
it("CLEARS a passive 'stop' wait when a background subagent fires a tool event", async () => {
// Passive-clear is desirable: a backgrounded subagent's activity should flip
// a merely-Stopped session back to active ("done/idle only while no agent works").
const sid = "guard-stop-pre";
await sessionWaitingWithWorkingSubagent(sid, { notification: false });
await hook("PreToolUse", { session_id: sid, tool_name: "Bash" });
const sess = await sessionOf(sid);
assert.equal(
sess.awaiting_input_since,
null,
"passive stop wait should be cleared by subagent activity"
);
assert.equal(sess.awaiting_reason, null);
});
it("CLEARS a 'notification' wait when MAIN (no working subagent) resumes with a tool", async () => {
// Control: main was waiting on the user, no subagent running. A PreToolUse
// means main itself resumed — clearing is correct (unchanged behaviour).
const sid = "guard-notif-mainactor";
await hook("SessionStart", { session_id: sid });
await hook("UserPromptSubmit", { session_id: sid, prompt: "go" });
await hook("Notification", { session_id: sid, message: "Claude is waiting for your input" });
let sess = await sessionOf(sid);
assert.ok(sess.awaiting_input_since && sess.awaiting_reason === "notification", "precondition");
await hook("PreToolUse", { session_id: sid, tool_name: "Bash" });
sess = await sessionOf(sid);
assert.equal(sess.awaiting_input_since, null, "main resuming must clear its own wait");
assert.equal(sess.awaiting_reason, null);
});
});
+825
View File
@@ -0,0 +1,825 @@
/**
* @file cc-config.test.js
* @description Tests for /api/cc-config — Claude Code configuration explorer.
* Builds a fake CLAUDE_HOME and project .claude/ in tmpdir, points the
* server at it, and exercises every surface plus path-containment guards,
* write/delete with backup, plugin contributions, marketplaces, keybindings,
* statusline, and hook scripts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const http = require("node:http");
// Build the fixture FIRST, set CLAUDE_HOME, then require the server. Order
// matters: claude-home.js caches the env var on first require.
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-config-test-"));
const FAKE_HOME = path.join(TMP, "home", ".claude");
const FAKE_PROJECT = path.join(TMP, "project");
const FAKE_PROJECT_CLAUDE = path.join(FAKE_PROJECT, ".claude");
fs.mkdirSync(path.join(FAKE_HOME, "skills", "demo-skill"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "agents"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "commands"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "plugins"), { recursive: true });
fs.mkdirSync(path.join(FAKE_PROJECT_CLAUDE, "skills", "proj-skill"), { recursive: true });
fs.mkdirSync(path.join(FAKE_PROJECT_CLAUDE, "agents"), { recursive: true });
fs.writeFileSync(
path.join(FAKE_HOME, "skills", "demo-skill", "SKILL.md"),
`---\nname: demo-skill\ndescription: A demo skill for tests\n---\n\nBody text here.`
);
fs.writeFileSync(
path.join(FAKE_HOME, "agents", "demo-agent.md"),
`---\nname: demo-agent\ntools: Read, Bash\nmodel: sonnet\n---\n\nAgent body.`
);
fs.writeFileSync(
path.join(FAKE_HOME, "commands", "deploy.md"),
`---\ndescription: ship it\n---\n\nDeploy command body.`
);
fs.writeFileSync(
path.join(FAKE_PROJECT_CLAUDE, "skills", "proj-skill", "SKILL.md"),
`---\nname: proj-skill\n---\n\nProject skill.`
);
fs.writeFileSync(
path.join(FAKE_PROJECT_CLAUDE, "agents", "proj-agent.md"),
`---\nname: proj-agent\n---\n\nProject agent.`
);
fs.writeFileSync(
path.join(FAKE_PROJECT_CLAUDE, "settings.local.json"),
JSON.stringify({
permissions: { allow: ["Bash(npm:*)"] },
hooks: {
Stop: [{ matcher: "*", hooks: [{ type: "command", command: "echo hi" }] }],
},
})
);
fs.writeFileSync(
path.join(FAKE_HOME, "settings.json"),
JSON.stringify({
model: "opus",
apiKeyHelper: "should-be-redacted",
hooks: {
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "node x.js" }] }],
},
})
);
// Build a minimal plugin install tree so contributions counter has something to count
const PLUGIN_INSTALL = path.join(FAKE_HOME, "plugins", "cache", "market", "demo-plugin", "1.0.0");
fs.mkdirSync(path.join(PLUGIN_INSTALL, ".claude-plugin"), { recursive: true });
fs.mkdirSync(path.join(PLUGIN_INSTALL, "skills", "plugin-skill"), { recursive: true });
fs.writeFileSync(
path.join(PLUGIN_INSTALL, "skills", "plugin-skill", "SKILL.md"),
"---\nname: x\n---\nbody"
);
fs.mkdirSync(path.join(PLUGIN_INSTALL, "agents"), { recursive: true });
fs.writeFileSync(path.join(PLUGIN_INSTALL, "agents", "plug-agent.md"), "---\nname: pa\n---\n");
fs.writeFileSync(
path.join(PLUGIN_INSTALL, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "demo-plugin", description: "Demo", version: "1.0.0" })
);
fs.writeFileSync(
path.join(FAKE_HOME, "plugins", "installed_plugins.json"),
JSON.stringify({
version: 2,
plugins: {
"demo-plugin@market": [
{
scope: "user",
installPath: PLUGIN_INSTALL,
version: "1.0.0",
installedAt: "2026-01-01T00:00:00Z",
},
],
},
})
);
// Marketplace fixture
const MARKETPLACE_DIR = path.join(FAKE_HOME, "plugins", "marketplaces", "demo-mp");
fs.mkdirSync(path.join(MARKETPLACE_DIR, ".claude-plugin"), { recursive: true });
fs.writeFileSync(
path.join(MARKETPLACE_DIR, ".claude-plugin", "marketplace.json"),
JSON.stringify({
name: "demo-mp",
description: "Demo marketplace",
owner: { name: "demo" },
plugins: [{ name: "p1" }, { name: "p2" }, { name: "p3" }],
})
);
fs.writeFileSync(
path.join(FAKE_HOME, "plugins", "known_marketplaces.json"),
JSON.stringify({
"demo-mp": {
source: { source: "github", repo: "demo/demo" },
installLocation: MARKETPLACE_DIR,
lastUpdated: "2026-01-15T00:00:00Z",
},
})
);
// Keybindings fixture
fs.writeFileSync(
path.join(FAKE_HOME, "keybindings.json"),
JSON.stringify({
$schema: "https://www.schemastore.org/x.json",
bindings: [
{ context: "Global", bindings: { "ctrl+t": "toggleTodos" } },
{ context: "Chat", bindings: { escape: "cancel", "ctrl+f": "killAgents" } },
],
})
);
// Statusline scripts
fs.writeFileSync(path.join(FAKE_HOME, "statusline.py"), "# fake statusline\nprint('ok')\n");
fs.writeFileSync(path.join(FAKE_HOME, "statusline-command.sh"), "#!/bin/sh\necho ok\n");
// Hook scripts dir
fs.mkdirSync(path.join(FAKE_HOME, "hooks"), { recursive: true });
fs.writeFileSync(path.join(FAKE_HOME, "hooks", "logger.py"), "# fake logger\n");
fs.writeFileSync(path.join(FAKE_HOME, "hooks", "scanner.py"), "# fake scanner\n");
// Mark a plugin as enabled in user settings
fs.writeFileSync(
path.join(FAKE_HOME, "settings.json"),
JSON.stringify({
model: "opus",
apiKeyHelper: "should-be-redacted",
statusLine: { type: "command", command: "sh /tmp/fake-status.sh" },
enabledPlugins: { "demo-plugin@market": true },
hooks: {
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "node x.js" }] }],
},
})
);
fs.writeFileSync(path.join(FAKE_PROJECT, "CLAUDE.md"), "# Project memory\nHello.");
// Per-project file-based memory store under ~/.claude/projects/<slug>/memory/
// (MEMORY.md index + one file per remembered fact). notes.txt is a non-md
// file that must be ignored.
const FAKE_AUTO_MEM = path.join(FAKE_HOME, "projects", "-Users-test-proj", "memory");
fs.mkdirSync(FAKE_AUTO_MEM, { recursive: true });
fs.writeFileSync(path.join(FAKE_AUTO_MEM, "MEMORY.md"), "- [Foo fact](foo.md) — a hook\n");
fs.writeFileSync(
path.join(FAKE_AUTO_MEM, "foo.md"),
"---\nname: foo\n---\nFoo fact body about widgets.\n"
);
fs.writeFileSync(path.join(FAKE_AUTO_MEM, "bar.md"), "Bar fact body.\n");
fs.writeFileSync(path.join(FAKE_AUTO_MEM, "notes.txt"), "ignored non-md\n");
process.env.CLAUDE_HOME = FAKE_HOME;
const TEST_DB = path.join(TMP, "dashboard-test.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp } = require("../index");
let server;
let BASE;
function fetchJson(p, opts = {}) {
return new Promise((resolve, reject) => {
const url = new URL(p, BASE);
const headers = { ...(opts.headers || {}) };
let bodyBuf;
if (opts.body !== undefined) {
bodyBuf = Buffer.from(JSON.stringify(opts.body));
headers["Content-Type"] = "application/json";
headers["Content-Length"] = bodyBuf.length;
}
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: opts.method || "GET",
headers,
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
let json;
try {
json = JSON.parse(body);
} catch {
json = body;
}
resolve({ status: res.statusCode, body: json });
});
}
);
req.on("error", reject);
if (bodyBuf) req.write(bodyBuf);
req.end();
});
}
describe("/api/cc-config", () => {
before(async () => {
const app = createApp();
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
const port = server.address().port;
BASE = `http://127.0.0.1:${port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
// On Windows rmSync can hit EPERM when a handle under TMP (fixture files /
// the OS releasing directory handles) is still held. maxRetries covers
// transient locks; the try/catch makes the rest best-effort — a leftover
// temp dir must not fail the suite (the OS reclaims os.tmpdir()).
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup */
}
});
it("overview reports counts and roots", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.equal(body.roots.claudeHome, FAKE_HOME);
assert.equal(body.roots.projectClaudeDir, FAKE_PROJECT_CLAUDE);
assert.equal(body.counts.skills.user, 1);
assert.equal(body.counts.skills.project, 1);
assert.equal(body.counts.agents.user, 1);
assert.equal(body.counts.commands.user, 1);
assert.equal(body.counts.plugins, 1);
// 1 project CLAUDE.md + 3 auto-memory files (MEMORY.md, foo.md, bar.md);
// notes.txt is ignored.
assert.equal(body.counts.memory, 4);
});
it("skills returns user + project items with parsed frontmatter", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/skills?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
const demo = body.items.find((s) => s.name === "demo-skill");
assert.equal(demo.scope, "user");
assert.equal(demo.frontmatter.name, "demo-skill");
assert.match(demo.preview, /Body text here/);
const proj = body.items.find((s) => s.name === "proj-skill");
assert.equal(proj.scope, "project");
});
it("scope=user filters out project items", async () => {
const { body } = await fetchJson(
`/api/cc-config/skills?scope=user&cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.ok(body.items.every((s) => s.scope === "user"));
});
it("agents parses tools/model frontmatter", async () => {
const { body } = await fetchJson(
`/api/cc-config/agents?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const a = body.items.find((x) => x.name === "demo-agent");
assert.equal(a.frontmatter.model, "sonnet");
assert.match(a.frontmatter.tools, /Read/);
});
it("plugins returns installed manifest with contributions and enabled state", async () => {
const { body } = await fetchJson("/api/cc-config/plugins");
assert.equal(body.manifestExists, true);
assert.equal(body.plugins.length, 1);
const p = body.plugins[0];
assert.equal(p.name, "demo-plugin");
assert.equal(p.marketplace, "market");
assert.equal(p.version, "1.0.0");
assert.equal(p.enabled, true);
assert.ok(p.contributes, "contributions surfaced");
assert.equal(p.contributes.skills, 1);
assert.equal(p.contributes.agents, 1);
assert.equal(p.contributes.commands, 0);
assert.equal(p.contributes.pluginJson.name, "demo-plugin");
});
it("marketplaces returns known marketplaces with plugin counts", async () => {
const { status, body } = await fetchJson("/api/cc-config/marketplaces");
assert.equal(status, 200);
assert.equal(body.knownExists, true);
assert.equal(body.items.length, 1);
const m = body.items[0];
assert.equal(m.name, "demo-mp");
assert.equal(m.pluginCount, 3);
assert.equal(m.marketplaceName, "demo-mp");
assert.equal(m.marketplaceOwner.name, "demo");
});
it("keybindings returns parsed groups", async () => {
const { body } = await fetchJson("/api/cc-config/keybindings");
assert.equal(body.exists, true);
assert.equal(body.groups.length, 2);
const chat = body.groups.find((g) => g.context === "Chat");
assert.ok(chat);
assert.equal(chat.bindings.length, 2);
const escapeBinding = chat.bindings.find((b) => b.key === "escape");
assert.equal(escapeBinding.action, "cancel");
});
it("statusline returns config + script content", async () => {
const { body } = await fetchJson("/api/cc-config/statusline");
assert.ok(body.config);
assert.equal(body.config.type, "command");
assert.equal(body.scripts.length, 2);
assert.match(body.scripts[0].preview, /fake statusline|fake/);
});
it("hook-scripts lists files inside ~/.claude/hooks/", async () => {
const { body } = await fetchJson("/api/cc-config/hook-scripts");
const names = body.items.map((i) => i.name).sort();
assert.deepEqual(names, ["logger.py", "scanner.py"]);
});
it("overview includes the new counters", async () => {
const { body } = await fetchJson(
`/api/cc-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(body.counts.marketplaces, 1);
assert.equal(body.counts.keybindings, 3);
assert.equal(body.counts.pluginsEnabled, 1);
assert.equal(body.counts.pluginsDisabled, 0);
});
it("hooks aggregates across user + project + project-local", async () => {
const { body } = await fetchJson(
`/api/cc-config/hooks?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const userSrc = body.items.find((x) => x.scope === "user");
assert.equal(userSrc.exists, true);
assert.equal(userSrc.hooks.PreToolUse.length, 1);
const local = body.items.find((x) => x.scope === "project-local");
assert.equal(local.exists, true);
assert.equal(local.hooks.Stop.length, 1);
});
it("settings redacts secret-like keys", async () => {
const { body } = await fetchJson(
`/api/cc-config/settings?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const userSettings = body.items.find((x) => x.scope === "user");
assert.equal(userSettings.exists, true);
assert.equal(userSettings.data.apiKeyHelper, "<redacted>");
assert.equal(userSettings.data.model, "opus");
});
it("memory returns project CLAUDE.md", async () => {
const { body } = await fetchJson(
`/api/cc-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const proj = body.items.find((x) => x.scope === "project");
assert.ok(proj);
assert.match(proj.preview, /Project memory/);
});
it("memory surfaces per-project auto-memory files (index sorted first)", async () => {
const { body } = await fetchJson(
`/api/cc-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const auto = body.items.filter((x) => x.scope === "auto-memory");
assert.equal(auto.length, 3); // MEMORY.md + foo.md + bar.md; notes.txt ignored
// Index file (MEMORY.md) sorts before the per-fact files.
assert.equal(auto[0].name, "MEMORY.md");
assert.equal(auto[0].isIndex, true);
assert.equal(auto[0].project, "-Users-test-proj");
const foo = auto.find((x) => x.name === "foo.md");
assert.ok(foo);
assert.equal(foo.isIndex, false);
assert.equal(foo.frontmatter.name, "foo"); // frontmatter parsed
assert.match(foo.preview, /Foo fact body/); // preview is the body, sans frontmatter
assert.doesNotMatch(foo.preview, /name: foo/);
});
it("file endpoint reads auto-memory files (they live under CLAUDE_HOME)", async () => {
const target = path.join(FAKE_AUTO_MEM, "foo.md");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.match(body.text, /Foo fact body/);
});
it("file endpoint reads inside CLAUDE_HOME", async () => {
const target = path.join(FAKE_HOME, "agents", "demo-agent.md");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.match(body.text, /Agent body/);
});
it("file endpoint blocks paths outside allowed roots", async () => {
const outside = path.join(TMP, "evil.md");
fs.writeFileSync(outside, "secret");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(outside)}`
);
assert.equal(status, 400);
assert.equal(body.error.code, "READ_DENIED");
});
it("file endpoint blocks .. traversal", async () => {
const tricky = path.join(FAKE_HOME, "..", "..", "etc", "passwd");
const { status } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(tricky)}`
);
assert.equal(status, 400);
});
it("file endpoint requires a path", async () => {
const { status, body } = await fetchJson("/api/cc-config/file");
assert.equal(status, 400);
assert.equal(body.error.code, "BAD_PATH");
});
// ── Phase 2: write/delete ─────────────────────────────────────────
it("PUT /file creates a new agent (no backup, file did not exist)", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "user",
type: "agents",
name: "fresh-agent",
content: `---\nname: fresh-agent\n---\n\nFresh body.`,
},
}
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.created, true);
assert.equal(body.backupPath, null);
assert.equal(fs.readFileSync(body.file, "utf8").includes("Fresh body"), true);
});
it("PUT /file overwrites an existing agent and creates a backup", async () => {
const before = fs.readFileSync(path.join(FAKE_HOME, "agents", "demo-agent.md"), "utf8");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "user",
type: "agents",
name: "demo-agent",
content: `---\nname: demo-agent\n---\n\nUpdated body.`,
},
}
);
assert.equal(status, 200);
assert.equal(body.created, false);
assert.ok(body.backupPath, "backup path returned");
assert.equal(fs.readFileSync(body.backupPath, "utf8"), before);
assert.match(fs.readFileSync(body.file, "utf8"), /Updated body/);
});
it("PUT /file creates a new skill dir with SKILL.md", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "user",
type: "skills",
name: "brand-new-skill",
content: `---\nname: brand-new-skill\n---\n\nHello.`,
},
}
);
assert.equal(status, 200);
assert.equal(body.created, true);
assert.ok(fs.existsSync(body.file));
assert.equal(path.basename(body.file), "SKILL.md");
});
it("PUT /file rejects malicious names (traversal)", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "user",
type: "agents",
name: "../../etc/passwd",
content: "evil",
},
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADNAME");
});
it("PUT /file rejects unknown type", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "plugins", name: "x", content: "y" },
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADTYPE");
});
it("PUT /file rejects oversize content", async () => {
const huge = "x".repeat(256 * 1024 + 1);
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "agents", name: "huge", content: huge },
}
);
assert.equal(status, 413);
assert.equal(body.error.code, "ETOOLARGE");
});
it("PUT /file edits memory CLAUDE.md without a name", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "project", type: "memory", content: "# new project memory" },
}
);
assert.equal(status, 200);
assert.ok(body.backupPath, "previous CLAUDE.md should be backed up");
assert.equal(fs.readFileSync(body.file, "utf8"), "# new project memory");
});
it("DELETE /file backs up and removes a single-file agent", async () => {
fs.writeFileSync(path.join(FAKE_HOME, "agents", "to-delete.md"), "bye");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "agents", name: "to-delete" },
}
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.ok(body.backupPath);
assert.equal(fs.existsSync(path.join(FAKE_HOME, "agents", "to-delete.md")), false);
assert.equal(fs.readFileSync(body.backupPath, "utf8"), "bye");
});
it("DELETE /file backs up and removes a skill dir (preserves bundled assets in backup)", async () => {
const skillDir = path.join(FAKE_HOME, "skills", "with-assets");
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: with-assets\n---\nbody");
fs.writeFileSync(path.join(skillDir, "asset.txt"), "important payload");
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "skills", name: "with-assets" },
}
);
assert.equal(status, 200);
assert.ok(body.backupPath);
assert.equal(fs.existsSync(skillDir), false);
assert.equal(
fs.readFileSync(path.join(body.backupPath, "asset.txt"), "utf8"),
"important payload"
);
});
it("DELETE /file 404s on a missing item", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "agents", name: "never-existed" },
}
);
assert.equal(status, 404);
assert.equal(body.error.code, "ENOTFOUND");
});
it("backups endpoint lists everything we just created", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
// We should have at least: demo-agent overwrite + memory overwrite +
// to-delete + with-assets dir.
assert.ok(body.items.length >= 4, `expected ≥4 backups, got ${body.items.length}`);
assert.ok(body.items.every((b) => typeof b.backupPath === "string"));
});
it("write is atomic: tmp file is gone after success", async () => {
await fetchJson(`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`, {
method: "PUT",
body: {
scope: "user",
type: "commands",
name: "atomic-test",
content: "---\ndescription: atomic\n---\nbody",
},
});
const cmdsDir = path.join(FAKE_HOME, "commands");
const stragglers = fs
.readdirSync(cmdsDir)
.filter((n) => n.startsWith(".atomic-test.md.") && n.endsWith(".tmp"));
assert.deepEqual(stragglers, []);
});
// ── auto-memory mutations (per-project file-based memory) ───────────────
const AUTO_SLUG = "-Users-test-proj";
it("PUT /file creates a new auto-memory fact file", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "auto-memory",
type: "auto-memory",
project: AUTO_SLUG,
name: "new_fact.md",
content: "---\nname: new-fact\n---\nA freshly written fact.",
},
}
);
assert.equal(status, 200);
assert.equal(body.created, true);
assert.equal(body.backupPath, null); // brand-new file → nothing to back up
assert.equal(
fs.readFileSync(path.join(FAKE_AUTO_MEM, "new_fact.md"), "utf8"),
"---\nname: new-fact\n---\nA freshly written fact."
);
});
it("PUT /file edits an existing auto-memory file and backs it up", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "auto-memory",
type: "auto-memory",
project: AUTO_SLUG,
name: "foo.md",
content: "edited foo body",
},
}
);
assert.equal(status, 200);
assert.ok(body.backupPath, "existing foo.md should be backed up");
assert.match(body.backupPath, /\.cc-config-backups[\\/]auto-memory[\\/]foo\.md\./);
assert.equal(fs.readFileSync(path.join(FAKE_AUTO_MEM, "foo.md"), "utf8"), "edited foo body");
});
it("DELETE /file backs up and removes an auto-memory file", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "auto-memory", type: "auto-memory", project: AUTO_SLUG, name: "bar.md" },
}
);
assert.equal(status, 200);
assert.ok(body.backupPath);
assert.equal(fs.existsSync(path.join(FAKE_AUTO_MEM, "bar.md")), false);
assert.equal(fs.readFileSync(body.backupPath, "utf8"), "Bar fact body.\n");
});
it("backups endpoint includes auto-memory backups (with project)", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
const auto = body.items.filter((b) => b.scope === "auto-memory");
assert.ok(auto.length >= 2, `expected ≥2 auto-memory backups, got ${auto.length}`);
assert.ok(auto.every((b) => b.project === AUTO_SLUG && b.type === "auto-memory"));
assert.ok(auto.some((b) => /^foo\.md\./.test(b.name)));
});
it("PUT /file rejects an auto-memory project slug that traverses", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "auto-memory",
type: "auto-memory",
project: "../../etc",
name: "x.md",
content: "evil",
},
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADPROJECT");
});
it("PUT /file rejects an auto-memory name without a .md extension", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: {
scope: "auto-memory",
type: "auto-memory",
project: AUTO_SLUG,
name: "../escape",
content: "evil",
},
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADNAME");
});
// ── Keybindings structured edit (PUT /keybindings) ─────────────────────
// These run last so the earlier overview count assertion (keybindings === 3)
// sees the original fixture before we rewrite the file here.
it("PUT /keybindings overwrites, backs up, and preserves top-level metadata", async () => {
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
method: "PUT",
body: {
groups: [
{
context: "Global",
bindings: [
{ key: "ctrl+t", action: "toggleTodos" },
{ key: "ctrl+n", action: "newThing" },
],
},
{ context: "Chat", bindings: [{ key: "escape", action: "cancel" }] },
],
},
});
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.created, false);
assert.ok(body.backupPath, "existing keybindings.json should be backed up");
const onDisk = JSON.parse(fs.readFileSync(path.join(FAKE_HOME, "keybindings.json"), "utf8"));
// $schema from the fixture must survive a structured rewrite.
assert.equal(onDisk.$schema, "https://www.schemastore.org/x.json");
const global = onDisk.bindings.find((g) => g.context === "Global");
assert.equal(global.bindings["ctrl+n"], "newThing");
assert.equal(Object.keys(onDisk.bindings.find((g) => g.context === "Chat").bindings).length, 1);
// Re-reading through the API returns the updated groups.
const after = await fetchJson("/api/cc-config/keybindings");
const chat = after.body.groups.find((g) => g.context === "Chat");
assert.equal(chat.bindings.length, 1);
});
it("PUT /keybindings rejects a duplicate key within one context", async () => {
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
method: "PUT",
body: {
groups: [
{
context: "Global",
bindings: [
{ key: "ctrl+t", action: "toggleTodos" },
{ key: "ctrl+t", action: "somethingElse" },
],
},
],
},
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCONTENT");
});
it("PUT /keybindings rejects a non-array groups payload", async () => {
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
method: "PUT",
body: { groups: "nope" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADREQ");
});
it("PUT /keybindings rejects an empty action", async () => {
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
method: "PUT",
body: { groups: [{ context: "Global", bindings: [{ key: "ctrl+z", action: "" }] }] },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCONTENT");
});
});
@@ -0,0 +1,334 @@
/**
* @file cc-discovery-helpers.test.js
* @description Direct unit tests for the exported helpers in
* `server/lib/cc-discovery.js`: parseFrontmatter, redactSettings, isUnder,
* isFileLike, the symlinked skill-directory and agent/command markdown-file
* discovery paths (readSkills / readAgents), and the MAX_FILE_BYTES
* constant. The integration tests in
* cc-config.test.js exercise these indirectly through HTTP routes; this
* file pins down their behavior at the function level so future refactors
* surface regressions immediately.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = 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 {
parseFrontmatter,
redactSettings,
isUnder,
isFileLike,
readSkills,
readAgents,
MAX_FILE_BYTES,
HOOK_EVENT_TYPES,
} = require("../lib/cc-discovery");
describe("parseFrontmatter", () => {
it("returns null frontmatter for plain markdown", () => {
const r = parseFrontmatter("# Just a heading\n\nNo frontmatter here.");
assert.equal(r.frontmatter, null);
assert.match(r.body, /Just a heading/);
});
it("parses simple key/value frontmatter", () => {
const r = parseFrontmatter("---\nname: my-skill\ndescription: A simple skill\n---\nbody text");
assert.equal(r.frontmatter.name, "my-skill");
assert.equal(r.frontmatter.description, "A simple skill");
assert.equal(r.body.trim(), "body text");
});
it("strips surrounding double quotes from values", () => {
const r = parseFrontmatter('---\nname: "quoted name"\n---\n');
assert.equal(r.frontmatter.name, "quoted name");
});
it("strips surrounding single quotes from values", () => {
const r = parseFrontmatter("---\nname: 'single quoted'\n---\n");
assert.equal(r.frontmatter.name, "single quoted");
});
it("preserves quotes inside the value", () => {
const r = parseFrontmatter('---\ndescription: text with "inner" quotes\n---\n');
assert.equal(r.frontmatter.description, 'text with "inner" quotes');
});
it("supports multiline indented continuation values", () => {
const r = parseFrontmatter(
"---\ndescription: line one\n line two\n line three\nname: x\n---\n"
);
assert.match(r.frontmatter.description, /line one/);
assert.match(r.frontmatter.description, /line two/);
assert.match(r.frontmatter.description, /line three/);
assert.equal(r.frontmatter.name, "x");
});
it("returns null frontmatter when --- is present but never closed", () => {
const r = parseFrontmatter("---\nname: never closed\nbody text below");
assert.equal(r.frontmatter, null);
// body falls back to the entire input
assert.match(r.body, /never closed/);
});
it("ignores lines that don't match key:value", () => {
const r = parseFrontmatter("---\nname: ok\nthis is not a key value\n---\n");
assert.equal(r.frontmatter.name, "ok");
assert.equal(r.frontmatter["this is not a key value"], undefined);
});
it("handles non-string input gracefully", () => {
const r1 = parseFrontmatter(null);
const r2 = parseFrontmatter(undefined);
const r3 = parseFrontmatter(123);
for (const r of [r1, r2, r3]) {
assert.equal(r.frontmatter, null);
assert.equal(r.body, "");
}
});
it("trims trailing whitespace in values", () => {
const r = parseFrontmatter("---\nname: trailing-space \n---\n");
assert.equal(r.frontmatter.name, "trailing-space");
});
it("body preserves its content verbatim after the closing ---", () => {
const r = parseFrontmatter("---\nname: x\n---\n\n# Heading\n\nParagraph.");
assert.match(r.body, /^# Heading/);
assert.match(r.body, /Paragraph\.$/);
});
});
describe("redactSettings", () => {
it("redacts string values whose key matches token/secret/password/key/auth", () => {
const input = {
apiKey: "sk-abc123",
authToken: "bearer xyz",
password: "hunter2",
secret_key: "shh",
apiSecret: "private",
regularField: "stays",
};
const out = redactSettings(input);
assert.equal(out.apiKey, "<redacted>");
assert.equal(out.authToken, "<redacted>");
assert.equal(out.password, "<redacted>");
assert.equal(out.secret_key, "<redacted>");
assert.equal(out.apiSecret, "<redacted>");
assert.equal(out.regularField, "stays");
});
it("handles api-key and api_key variants", () => {
const out = redactSettings({ "api-key": "x", api_key: "y", apikey: "z" });
assert.equal(out["api-key"], "<redacted>");
assert.equal(out.api_key, "<redacted>");
assert.equal(out.apikey, "<redacted>");
});
it("does not redact non-string values even if key matches", () => {
// The redactor only swaps STRING values; nested objects/arrays/numbers
// pass through unchanged so structure is preserved.
const out = redactSettings({ apiKey: 12345, secrets: { x: "stay" } });
assert.equal(out.apiKey, 12345);
assert.deepEqual(out.secrets, { x: "stay" });
});
it("recurses into nested objects", () => {
const out = redactSettings({
provider: { name: "anthropic", apiKey: "sk-...", endpoint: "https://api" },
});
assert.equal(out.provider.apiKey, "<redacted>");
assert.equal(out.provider.name, "anthropic");
assert.equal(out.provider.endpoint, "https://api");
});
it("recurses into arrays of objects", () => {
const out = redactSettings({
mcpServers: [
{ name: "a", apiKey: "sk-1" },
{ name: "b", token: "tok-2" },
],
});
assert.equal(out.mcpServers[0].apiKey, "<redacted>");
assert.equal(out.mcpServers[1].token, "<redacted>");
assert.equal(out.mcpServers[0].name, "a");
});
it("passes scalars through unchanged", () => {
assert.equal(redactSettings(null), null);
assert.equal(redactSettings(undefined), undefined);
assert.equal(redactSettings(42), 42);
assert.equal(redactSettings(true), true);
assert.equal(redactSettings("plain"), "plain");
});
it("is case-insensitive on key match", () => {
const out = redactSettings({ APIKEY: "x", AuthHeader: "y", ToKeN: "z" });
assert.equal(out.APIKEY, "<redacted>");
assert.equal(out.AuthHeader, "<redacted>");
assert.equal(out.ToKeN, "<redacted>");
});
it("does not match keys that contain unrelated substrings", () => {
// 'kept' contains 'kep' not 'key' - should stay
const out = redactSettings({ kept: "stays", description: "stays too" });
assert.equal(out.kept, "stays");
assert.equal(out.description, "stays too");
});
});
describe("isUnder", () => {
it("returns true when target equals root", () => {
assert.equal(isUnder("/a/b", "/a/b"), true);
});
it("returns true when target is inside root", () => {
assert.equal(isUnder("/a/b", "/a/b/c"), true);
assert.equal(isUnder("/a/b", "/a/b/c/d/e.txt"), true);
});
it("returns false when target is outside root", () => {
assert.equal(isUnder("/a/b", "/a/c"), false);
assert.equal(isUnder("/a/b", "/x/y"), false);
});
it("rejects sibling paths that share a prefix", () => {
// /a/b vs /a/bb - bb starts with b but is not under /a/b
assert.equal(isUnder("/a/b", "/a/bb"), false);
assert.equal(isUnder("/a/b", "/a/b-extra"), false);
});
it("normalises relative segments via path.resolve", () => {
// .. inside the target gets resolved away
assert.equal(isUnder("/a/b", "/a/b/c/../d"), true); // /a/b/d
assert.equal(isUnder("/a/b", "/a/b/../c"), false); // /a/c
});
it("returns true regardless of trailing slash", () => {
assert.equal(isUnder("/a/b/", "/a/b/c"), true);
assert.equal(isUnder("/a/b", "/a/b/c/"), true);
});
it("works with paths produced by path.resolve", () => {
const root = path.resolve("/tmp/cc-test");
const inside = path.resolve("/tmp/cc-test/skills/x/SKILL.md");
const outside = path.resolve("/tmp/elsewhere/file");
assert.equal(isUnder(root, inside), true);
assert.equal(isUnder(root, outside), false);
});
});
describe("readSkills (symlinked skill directories)", () => {
it("includes a skill directory that is a symlink to a real directory", () => {
// Dirent.isDirectory() returns false for a symlink even when it points
// to a directory (Node fs quirk) — readSkillsAt must still follow it.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-symlink-"));
const realDir = path.join(tmp, "real-skills", "second-brain");
fs.mkdirSync(realDir, { recursive: true });
fs.writeFileSync(
path.join(realDir, "SKILL.md"),
"---\nname: second-brain\n---\n\nBody text.\n"
);
const projectRoot = path.join(tmp, "project");
const skillsDir = path.join(projectRoot, ".claude", "skills");
fs.mkdirSync(skillsDir, { recursive: true });
fs.symlinkSync(realDir, path.join(skillsDir, "second-brain"), "dir");
const items = readSkills({ scope: "project", cwd: projectRoot });
const names = items.map((s) => s.name);
assert.ok(names.includes("second-brain"), `expected symlinked skill in ${names}`);
});
it("skips a broken symlink without throwing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-broken-symlink-"));
const projectRoot = path.join(tmp, "project");
const skillsDir = path.join(projectRoot, ".claude", "skills");
fs.mkdirSync(skillsDir, { recursive: true });
fs.symlinkSync(path.join(tmp, "does-not-exist"), path.join(skillsDir, "broken"), "dir");
let items;
assert.doesNotThrow(() => {
items = readSkills({ scope: "project", cwd: projectRoot });
});
assert.ok(!items.map((s) => s.name).includes("broken"));
});
});
describe("readAgents (symlinked markdown files)", () => {
it("includes an agent .md that is a symlink to a real file", () => {
// Same Dirent quirk as symlinked skill directories, but for files:
// ent.isFile() is false for a symlink pointing at a regular file, so a
// version-controlled agent linked into .claude/agents/ was invisible.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-symlink-"));
const realFile = path.join(tmp, "repo", "reviewer.md");
fs.mkdirSync(path.dirname(realFile), { recursive: true });
fs.writeFileSync(realFile, "---\nname: reviewer\ndescription: Reviews diffs\n---\n\nBody.\n");
const projectRoot = path.join(tmp, "project");
const agentsDir = path.join(projectRoot, ".claude", "agents");
fs.mkdirSync(agentsDir, { recursive: true });
fs.symlinkSync(realFile, path.join(agentsDir, "reviewer.md"), "file");
const items = readAgents({ scope: "project", cwd: projectRoot });
const names = items.map((a) => a.name);
assert.ok(names.includes("reviewer"), `expected symlinked agent in ${names}`);
});
it("skips a broken .md symlink without throwing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-broken-"));
const projectRoot = path.join(tmp, "project");
const agentsDir = path.join(projectRoot, ".claude", "agents");
fs.mkdirSync(agentsDir, { recursive: true });
fs.symlinkSync(path.join(tmp, "gone.md"), path.join(agentsDir, "ghost.md"), "file");
let items;
assert.doesNotThrow(() => {
items = readAgents({ scope: "project", cwd: projectRoot });
});
assert.ok(!items.some((a) => /ghost/.test(a.file || "")));
});
});
describe("isFileLike", () => {
it("true for a regular file dirent", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-"));
fs.writeFileSync(path.join(tmp, "a.md"), "x");
const ent = fs.readdirSync(tmp, { withFileTypes: true })[0];
assert.equal(isFileLike(ent, path.join(tmp, ent.name)), true);
});
it("true for a symlink resolving to a file, false for one resolving to a directory", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-sym-"));
fs.writeFileSync(path.join(tmp, "real.md"), "x");
fs.mkdirSync(path.join(tmp, "realdir"));
fs.symlinkSync(path.join(tmp, "real.md"), path.join(tmp, "file-link.md"), "file");
fs.symlinkSync(path.join(tmp, "realdir"), path.join(tmp, "dir-link"), "dir");
const ents = Object.fromEntries(
fs.readdirSync(tmp, { withFileTypes: true }).map((e) => [e.name, e])
);
assert.equal(isFileLike(ents["file-link.md"], path.join(tmp, "file-link.md")), true);
assert.equal(isFileLike(ents["dir-link"], path.join(tmp, "dir-link")), false);
});
});
describe("module exports", () => {
it("MAX_FILE_BYTES is 256 KB", () => {
assert.equal(MAX_FILE_BYTES, 256 * 1024);
});
it("HOOK_EVENT_TYPES covers all canonical Claude Code events", () => {
const expected = [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"Notification",
"PreCompact",
];
for (const t of expected) assert.ok(HOOK_EVENT_TYPES.includes(t), `missing ${t}`);
});
});
+796
View File
@@ -0,0 +1,796 @@
/**
* @file Tests for the `ccam` umbrella CLI (bin/ccam.js). Spawns the real CLI
* as a subprocess against a live in-test dashboard server and asserts each
* command's output shape across the whole surface: monitoring (health, stats,
* kanban), data browsing (sessions, session detail, agents, events), insights
* (analytics, workflows, runs, cost), alerts/rules/webhooks, pricing CRUD,
* import, administration (doctor, info, export, cleanup, clear-data guard),
* plus help and error paths.
*
* The CLI must be spawned ASYNCHRONOUSLY (child_process.spawn, not
* spawnSync): the dashboard server lives in THIS process, so a synchronous
* spawn would block the event loop and deadlock every request the child
* makes. Port targeting uses the DASHBOARD_PORT env override, which takes
* precedence over ~/.claude/.agent-dashboard.json discovery.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const { spawn } = require("child_process");
const STAMP = `ccam-cli-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const CLI = path.resolve(__dirname, "..", "..", "bin", "ccam.js");
let server;
let PORT;
/**
* Run the CLI asynchronously against the test server. Async is load-bearing:
* the server runs in this same process, so a blocking spawnSync would starve
* its event loop and every CLI request would hang until timeout.
*/
function ccam(...args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [CLI, ...args], {
env: { ...process.env, DASHBOARD_PORT: String(PORT) },
});
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
const killer = setTimeout(() => child.kill("SIGKILL"), 20_000);
child.on("close", (code) => {
clearTimeout(killer);
resolve({ code, out, err });
});
});
}
function post(urlPath, body) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const req = http.request(
{
hostname: "127.0.0.1",
port: PORT,
path: urlPath,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
PORT = server.address().port;
// Seed one session via the real hook path so list commands have a row.
const code = await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "cli-test-session-0001", cwd: "/tmp/ccam-cli" },
});
assert.equal(code, 200);
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.rmSync(TMP, { recursive: true, force: true });
} catch {
/* ignore */
}
});
describe("ccam CLI — monitoring", () => {
it("health reports the dashboard as up", async () => {
const { code, out } = await ccam("health");
assert.equal(code, 0);
assert.match(out, /Dashboard/);
assert.match(out, /up/);
assert.match(out, /v\d+\.\d+\.\d+/);
assert.match(out, new RegExp(String(PORT)));
});
it("stats prints totals including the seeded session", async () => {
const { code, out } = await ccam("stats");
assert.equal(code, 0);
assert.match(out, /Total sessions/);
assert.match(out, /Events today/);
assert.match(out, /Sessions by status/);
});
it("kanban groups sessions and agents into status columns", async () => {
const { code, out } = await ccam("kanban");
assert.equal(code, 0);
assert.match(out, /Sessions/);
assert.match(out, /Agents/);
assert.match(out, /active \(1\)/);
assert.match(out, /waiting \(1\)/); // the main agent lands in waiting after Stop
});
});
describe("ccam CLI — data browsing", () => {
it("sessions lists the seeded session", async () => {
const { code, out } = await ccam("sessions", "--limit", "5");
assert.equal(code, 0);
assert.match(out, /cli-test/);
assert.match(out, /of 1 session/);
});
it("sessions --status filters correctly", async () => {
const { code, out } = await ccam("sessions", "--status", "completed");
assert.equal(code, 0);
assert.match(out, /of 0 session/);
});
it("session <id> shows detail with agents and recent events", async () => {
const { code, out } = await ccam("session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /cli-test-session-0001/);
assert.match(out, /Agents/);
assert.match(out, /Recent events/);
});
it("session without an id exits 1 with usage", async () => {
const { code, err } = await ccam("session");
assert.equal(code, 1);
assert.match(err, /Usage: ccam session/);
});
it("agents lists the auto-created main agent", async () => {
const { code, out } = await ccam("agents", "--session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /main/);
});
it("events lists the seeded Stop event", async () => {
const { code, out } = await ccam("events", "--session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /Stop/);
});
});
describe("ccam CLI — insights", () => {
it("analytics prints token totals and averages", async () => {
const { code, out } = await ccam("analytics");
assert.equal(code, 0);
assert.match(out, /Tokens/);
assert.match(out, /Input/);
});
it("workflows prints intelligence stats", async () => {
const { code, out } = await ccam("workflows");
assert.equal(code, 0);
assert.match(out, /Workflow intelligence/);
assert.match(out, /Sessions analyzed/);
});
it("runs lists dynamic workflow runs (empty is fine)", async () => {
const { code, out } = await ccam("runs");
assert.equal(code, 0);
assert.match(out, /Run/);
assert.match(out, /Status/);
});
it("cost prints a total", async () => {
const { code, out } = await ccam("cost");
assert.equal(code, 0);
assert.match(out, /Total estimated cost: \$/);
});
it("cost --session scopes the total to one session", async () => {
const { code, out } = await ccam("cost", "--session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /Session cost/);
assert.match(out, /cli-test-session-0001/);
assert.match(out, /Total estimated cost: \$/);
});
it("cost surfaces server-tool surcharges when web search was billed", async () => {
// Feature surcharges accrue independent of per-model pricing rules, so a
// web_search_requests count alone drives the line ($10 / 1k searches).
db.prepare(
"INSERT INTO token_usage (session_id, model, web_search_requests) VALUES (?, ?, ?)"
).run("cli-test-session-0001", "claude-sonnet-5", 200);
try {
const { code, out } = await ccam("cost", "--session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /Server-tool surcharges/);
assert.match(out, /web search \$/);
} finally {
db.prepare(
"DELETE FROM token_usage WHERE session_id = 'cli-test-session-0001' AND model = 'claude-sonnet-5'"
).run();
}
});
it("cost warns about models with usage but no pricing rule", async () => {
// Usage on a model no default pattern matches: the API prices it at $0
// and reports it via unpriced_models; the CLI must surface that.
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens, output_tokens) VALUES (?, ?, ?, ?)"
).run("cli-test-session-0001", "ccam-mystery-model-9", 1200, 300);
try {
const { code, out } = await ccam("cost");
assert.equal(code, 0);
assert.match(out, /no pricing rule/);
assert.match(out, /ccam-mystery-model-9/);
assert.match(out, /ccam pricing set/);
} finally {
db.prepare("DELETE FROM token_usage WHERE model = 'ccam-mystery-model-9'").run();
}
});
});
describe("ccam CLI — alerts, rules, webhooks", () => {
it("alerts lists the (empty) fired-alert feed", async () => {
const { code, out } = await ccam("alerts");
assert.equal(code, 0);
assert.match(out, /unacknowledged of/);
});
it("alerts ack-all succeeds on an empty feed", async () => {
const { code, out } = await ccam("alerts", "ack-all");
assert.equal(code, 0);
assert.match(out, /Acknowledged/);
});
it("rules lists alert rules", async () => {
const { code, out } = await ccam("rules");
assert.equal(code, 0);
assert.match(out, /Enabled/);
});
it("webhooks lists targets", async () => {
const { code, out } = await ccam("webhooks");
assert.equal(code, 0);
assert.match(out, /Provider/);
});
});
describe("ccam CLI — pricing", () => {
it("pricing lists the default rules", async () => {
const { code, out } = await ccam("pricing");
assert.equal(code, 0);
assert.match(out, /Pattern/);
assert.match(out, /claude/);
});
it("pricing set / delete round-trips a custom rule", async () => {
const set = await ccam(
"pricing",
"set",
"ccam-test-model%",
"--input",
"1",
"--output",
"2",
"--cache-read",
"0.1",
"--cache-write",
"1.25"
);
assert.equal(set.code, 0, `stderr: ${set.err} stdout: ${set.out}`);
assert.match(set.out, /saved/);
const list = await ccam("pricing");
assert.match(list.out, /ccam-test-model%/);
const del = await ccam("pricing", "delete", "ccam-test-model%");
assert.equal(del.code, 0);
assert.match(del.out, /deleted/);
});
it("pricing set persists fast-mode and intro rates via flags", async () => {
const set = await ccam(
"pricing",
"set",
"ccam-fast-model%",
"--input",
"5",
"--output",
"25",
"--fast-input",
"10",
"--fast-output",
"50",
"--intro-input",
"2",
"--intro-output",
"10",
"--intro-until",
"2099-01-01"
);
assert.equal(set.code, 0, `stderr: ${set.err} stdout: ${set.out}`);
const list = await ccam("pricing");
assert.match(list.out, /ccam-fast-model%/);
assert.match(list.out, /\$10\/\$50/); // fast in/out column
assert.match(list.out, /\$2\/\$10/); // intro in/out column
assert.match(list.out, /2099-01-01/);
// A plain rate edit without intro flags must preserve the promo (the
// intro block is only sent when an --intro-* flag is present).
const edit = await ccam("pricing", "set", "ccam-fast-model%", "--input", "6", "--output", "30");
assert.equal(edit.code, 0);
const after = await ccam("pricing");
assert.match(after.out, /\$6/);
assert.match(after.out, /2099-01-01/);
await ccam("pricing", "delete", "ccam-fast-model%");
});
});
describe("ccam CLI — import & administration", () => {
it("import rescan runs against the (empty) default projects dir", async () => {
const { code, out } = await ccam("import", "rescan");
assert.equal(code, 0);
assert.match(out, /imported \d+/);
});
it("import without a subcommand exits 1 with usage", async () => {
const { code, err } = await ccam("import");
assert.equal(code, 1);
assert.match(err, /Usage: ccam import/);
});
it("doctor reports API, hooks, database, remotes, and uptime lines", async () => {
const { code, out } = await ccam("doctor");
// Exit 1 when hooks are missing (common in the test harness) — still prints a full report.
assert.ok(code === 0 || code === 1, `unexpected exit ${code}`);
assert.match(out, /API reachable/);
assert.match(out, /Claude Code hooks/);
assert.match(out, /Database/);
assert.match(out, /Server uptime/);
assert.match(out, /Remote sources/);
});
it("info dumps system info JSON", async () => {
const { code, out } = await ccam("info");
assert.equal(code, 0);
const parsed = JSON.parse(out);
assert.ok(parsed.db);
assert.ok(parsed.server);
});
it("export writes a JSON file containing the seeded session", async () => {
const file = path.join(TMP, "export.json");
const { code, out } = await ccam("export", file);
assert.equal(code, 0);
assert.match(out, /Exported to/);
const data = JSON.parse(fs.readFileSync(file, "utf8"));
assert.ok(JSON.stringify(data).includes("cli-test-session-0001"));
});
it("update-check reports the checkout's update status", async () => {
// The test process runs inside the real repo clone, so the route always
// reports git_repo: true; the CLI exits 0 whether the checkout is behind,
// current, or the remote is unreachable (fetch errors are informational).
const { code, out } = await ccam("update-check");
assert.equal(code, 0);
assert.match(out, /Dashboard updates/);
assert.match(out, /checkout|commit|remote|update/i);
});
it("cleanup without flags exits 1 with usage", async () => {
const { code, err } = await ccam("cleanup");
assert.equal(code, 1);
assert.match(err, /Usage: ccam cleanup/);
});
it("cleanup with flags runs", async () => {
const { code, out } = await ccam("cleanup", "--days", "3650");
assert.equal(code, 0);
assert.match(out, /Cleanup done/);
});
it("clear-data REFUSES without --yes", async () => {
const { code, err } = await ccam("clear-data");
assert.equal(code, 1);
assert.match(err, /--yes/);
// Data must be intact.
const { out } = await ccam("sessions");
assert.match(out, /of 1 session/);
});
it("clear-data --yes wipes rows (runs last)", async () => {
const { code, out } = await ccam("clear-data", "--yes");
assert.equal(code, 0);
assert.match(out, /cleared/);
const { out: after } = await ccam("sessions");
assert.match(after, /of 0 session/);
});
});
describe("ccam CLI — offline mode (server down, DB read directly)", () => {
// The online admin suite ends with clear-data, so re-seed one session here
// (through the live server) for the offline reads to find.
before(async () => {
const code = await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "cli-test-offline-0002", cwd: "/tmp/ccam-cli-off" },
});
assert.equal(code, 200);
});
/** Run the CLI with an unreachable port so it falls back to offline reads
* of the SAME database file the in-test server uses (WAL second reader). */
function offline(...args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [CLI, ...args], {
env: { ...process.env, DASHBOARD_PORT: "1" },
});
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
const killer = setTimeout(() => child.kill("SIGKILL"), 20_000);
child.on("close", (code) => {
clearTimeout(killer);
resolve({ code, out, err });
});
});
}
it("sessions falls back to offline reads with the banner", async () => {
const { code, out } = await offline("sessions", "--limit", "5");
assert.equal(code, 0);
assert.match(out, /Offline mode/);
assert.match(out, /ccam start/);
assert.match(out, /cli-test/);
assert.match(out, /of 1 session/);
});
it("stats works offline", async () => {
const { code, out } = await offline("stats");
assert.equal(code, 0);
assert.match(out, /Total sessions/);
assert.match(out, /offline/);
});
it("session <id> works offline and flags cost as server-only", async () => {
const { code, out } = await offline("session", "cli-test-offline-0002");
assert.equal(code, 0);
assert.match(out, /Agents/);
assert.match(out, /Cost\s+requires the server/);
});
it("kanban works offline", async () => {
const { code, out } = await offline("kanban");
assert.equal(code, 0);
assert.match(out, /Sessions/);
assert.match(out, /Agents/);
});
it("pricing list works offline; pricing set is refused with a reason", async () => {
const list = await offline("pricing");
assert.equal(list.code, 0);
assert.match(list.out, /claude/);
const set = await offline("pricing", "set", "x%", "--input", "1", "--output", "1");
assert.equal(set.code, 1);
assert.match(set.err, /pricing changes must go through the server/);
});
it("rules and alerts list offline", async () => {
const rules = await offline("rules");
assert.equal(rules.code, 0);
const alerts = await offline("alerts");
assert.equal(alerts.code, 0);
assert.match(alerts.out, /unacknowledged of/);
});
it("export works offline and marks the payload", async () => {
const file = path.join(TMP, "offline-export.json");
const { code } = await offline("export", file);
assert.equal(code, 0);
const data = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(data.exported_offline, true);
assert.ok(JSON.stringify(data.sessions).includes("cli-test-offline-0002"));
});
it("doctor works offline and reports the server as down", async () => {
const { code, out } = await offline("doctor");
assert.equal(code, 1);
assert.match(out, /NOT running/);
assert.match(out, /Database/);
assert.match(out, /rows: sessions/);
assert.match(out, /Remote sources/);
});
it("cost refuses offline with the server-side-math reason", async () => {
const { code, err } = await offline("cost");
assert.equal(code, 1);
assert.match(err, /cost math .* runs server-side/);
});
it("clear-data --yes refuses offline — data stays intact", async () => {
const { code, err } = await offline("clear-data", "--yes");
assert.equal(code, 1);
assert.match(err, /data wipes must go through the server/);
const { out } = await offline("sessions");
assert.match(out, /of 1 session/);
});
it("prints the staleness caveat when the liveness probe is unavailable", async () => {
// The suite env sets DASHBOARD_LIVENESS_PROBE=0, so the probe reports
// unavailable and offline output must carry the "statuses as stored"
// caveat instead of silently showing possibly-stale active rows.
const { code, out } = await offline("sessions");
assert.equal(code, 0);
assert.match(out, /Statuses are as stored/);
});
it("corrects dead active sessions display-side when the probe can answer", async (t) => {
// Only meaningful where the real probe works (macOS/Linux, not container).
const { spawnSync } = require("child_process");
const avail = spawnSync(
process.execPath,
[
"-e",
"const p=require(process.argv[1]).probeLiveCwds();console.log(p.available)",
path.resolve(__dirname, "..", "lib", "session-liveness.js"),
],
{ encoding: "utf8", env: { ...process.env, DASHBOARD_LIVENESS_PROBE: "" } }
).stdout.trim();
if (avail !== "true") {
t.skip("liveness probe unavailable on this platform");
return;
}
// Run offline WITHOUT the probe kill-switch: the seeded session's cwd
// (/tmp/ccam-cli-off) has no running claude process, so its stored
// "active" status must be DISPLAYED as completed, with the footnote —
// and the database itself must keep the stored status.
const r = await new Promise((resolve) => {
const child = spawn(process.execPath, [CLI, "sessions"], {
env: { ...process.env, DASHBOARD_PORT: "1", DASHBOARD_LIVENESS_PROBE: "" },
});
let out = "";
child.stdout.on("data", (d) => (out += d));
child.on("close", (code) => resolve({ code, out }));
});
assert.equal(r.code, 0);
assert.match(r.out, /displayed as completed by the process-liveness probe/);
// DB unchanged: the stored status is still whatever the server left there.
const stored = db
.prepare("SELECT status FROM sessions WHERE id = 'cli-test-offline-0002'")
.get();
assert.equal(stored.status, "active");
});
it("analytics/workflows/tail refuse offline with reasons", async () => {
for (const cmd of ["analytics", "workflows", "tail"]) {
const { code, err } = await offline(cmd);
assert.equal(code, 1, `${cmd} should refuse offline`);
assert.match(err, /No offline fallback/);
}
});
});
describe("ccam CLI — help & errors", () => {
it("help lists every command group", async () => {
const { code, out } = await ccam("help");
assert.equal(code, 0);
for (const word of [
"status",
"start",
"health",
"stats",
"kanban",
"tail",
"sessions",
"session <id>",
"agents",
"events",
"analytics",
"workflows",
"runs",
"cost",
"alerts",
"rules",
"webhooks",
"pricing",
"import",
"doctor",
"info",
"export",
"cleanup",
"reinstall-hooks",
"update-check",
"clear-data",
"open",
]) {
assert.ok(out.includes(word), `help should mention ${word}`);
}
});
it("no arguments prints help and exits 0", async () => {
const { code, out } = await ccam();
assert.equal(code, 0);
assert.match(out, /Usage: ccam <command>/);
});
it("version prints the package version", async () => {
const pkg = JSON.parse(
fs.readFileSync(path.resolve(__dirname, "..", "..", "package.json"), "utf8")
);
const { code, out } = await ccam("version");
assert.equal(code, 0);
assert.equal(out.trim(), `ccam ${pkg.version}`);
const flag = await ccam("--version");
assert.equal(flag.out.trim(), `ccam ${pkg.version}`);
});
it("--no-color is accepted anywhere on the command line", async () => {
const before = await ccam("--no-color", "health");
assert.equal(before.code, 0);
assert.match(before.out, /Dashboard/);
const after = await ccam("sessions", "--no-color");
assert.equal(after.code, 0);
assert.match(after.out, /of 1 session/);
});
it("piped output contains no ANSI escape codes (colors off when not a TTY)", async () => {
const { out } = await ccam("stats");
assert.ok(!out.includes("\x1b["), "piped output must be plain text");
});
it("tables render with box-drawing borders and an Updated column", async () => {
const { out } = await ccam("sessions");
assert.ok(out.includes("╭"), "table should have a top border");
assert.ok(out.includes("│"), "table should have column separators");
assert.match(out, /Updated/);
});
it("unknown command exits 1 with an error", async () => {
const { code, err } = await ccam("frobnicate");
assert.equal(code, 1);
assert.match(err, /Unknown command/);
});
it("unreachable server exits 1 with the not-running indicator + start hint", async () => {
const r = await new Promise((resolve) => {
const child = spawn(process.execPath, [CLI, "health"], {
env: { ...process.env, DASHBOARD_PORT: "1" }, // nothing listens on port 1
});
let err = "";
child.stderr.on("data", (d) => (err += d));
child.on("close", (code) => resolve({ code, err }));
});
assert.equal(r.code, 1);
assert.match(r.err, /Dashboard server is NOT running/);
assert.match(r.err, /ccam start/);
assert.match(r.err, /npm run dev/);
});
it("status reports running when the server is up", async () => {
const { code, out } = await ccam("status");
assert.equal(code, 0);
assert.match(out, /running/);
assert.match(out, new RegExp(String(PORT)));
});
it("status exits 1 with the down indicator when the server is down", async () => {
const r = await new Promise((resolve) => {
const child = spawn(process.execPath, [CLI, "status"], {
env: { ...process.env, DASHBOARD_PORT: "1" },
});
let out = "";
child.stdout.on("data", (d) => (out += d));
child.on("close", (code) => resolve({ code, out }));
});
assert.equal(r.code, 1);
assert.match(r.out, /NOT running/);
assert.match(r.out, /ccam start/);
});
it("start no-ops with a pointer when a server is already running", async () => {
const { code, out } = await ccam("start");
assert.equal(code, 0);
assert.match(out, /already running/);
});
});
describe("ccam CLI — interactive REPL", () => {
/**
* Drive `ccam repl` with piped stdin (non-TTY). Each typed line is executed
* as a child `ccam` process, so grandchildren reach the in-test server via
* the DASHBOARD_PORT override; async spawn keeps the event loop free.
*/
function repl(input, port = PORT) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [CLI, "repl"], {
env: { ...process.env, DASHBOARD_PORT: String(port) },
});
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
const killer = setTimeout(() => child.kill("SIGKILL"), 20_000);
child.on("close", (code) => {
clearTimeout(killer);
resolve({ code, out, err });
});
child.stdin.write(input);
child.stdin.end();
});
}
it("runs piped commands in order and exits at EOF", async () => {
const { code, out } = await repl("version\nstats\nexit\n");
assert.equal(code, 0);
// version output precedes the stats table → sequential execution.
const vIdx = out.indexOf("ccam ");
const sIdx = out.indexOf("Total sessions");
assert.ok(vIdx >= 0 && sIdx >= 0 && vIdx < sIdx, `order: v=${vIdx} s=${sIdx}`);
});
it("dispatches real commands (sessions) through child processes", async () => {
const { code, out } = await repl("sessions --limit 5\nexit\n");
assert.equal(code, 0);
assert.match(out, /of \d+ session/);
});
it("the `commands` built-in lists every command", async () => {
const { out } = await repl("commands\nexit\n");
assert.match(out, /sessions/);
assert.match(out, /kanban/);
assert.match(out, /repl/);
});
it("the `help` built-in shows shell built-ins and the grouped catalog", async () => {
const { out } = await repl("help\nexit\n");
assert.match(out, /built-ins/i);
assert.match(out, /Server/); // catalog group headers are present
assert.match(out, /Administration/);
assert.match(out, /watch/); // the new built-in is documented
});
it("`help <command>` shows that command's details", async () => {
const { out } = await repl("help sessions\nexit\n");
assert.match(out, /sessions/);
assert.match(out, /--status/); // the args hint / description is shown
});
it("`commands` groups every command under its category", async () => {
const { out } = await repl("commands\nexit\n");
assert.match(out, /Server/);
assert.match(out, /Insights/);
assert.match(out, /sessions/);
});
it("an unknown command does not kill the shell — later commands still run", async () => {
const { code, out, err } = await repl("frobnicate\nstats\nexit\n");
assert.equal(code, 0);
assert.match(err, /Unknown command/); // the refusal lands on stderr
assert.match(out, /Total sessions/); // the shell survived and ran stats
});
it("a server-only refusal (offline) does not kill the shell", async () => {
// Point the shell at a dead port: `cost` refuses, but the shell survives
// to run `exit` and close cleanly.
const { code, out, err } = await repl("cost\nexit\n", 1);
assert.equal(code, 0);
assert.match(err + out, /runs server-side|NOT running/);
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* @file Round-trip correctness for the full-dataset export/import (backup /
* restore) in server/lib/data-transfer.js.
*
* Verifies the two product requirements:
* 1. Export captures ALL user data (sessions, agents, events, token_usage,
* workflows, dashboard_runs, alert_rules, model_pricing) with a
* format/version stamp.
* 2. Re-importing that export reproduces the data accurately, is idempotent
* (re-import skips existing sessions, never duplicates), and merges cleanly
* when consolidating another machine's sessions into an existing DB.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `dashboard-data-transfer-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const {
buildExportBundle,
importExportBundle,
EXPORT_FORMAT,
EXPORT_VERSION,
} = require("../lib/data-transfer");
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
function seedSession(id, { events = 2 } = {}) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at) VALUES (?,?,?,?,?,?,?)"
).run(
id,
`Session ${id}`,
"completed",
"/tmp/x",
"claude-opus-4-8",
"2026-06-01T00:00:00.000Z",
"2026-06-01T01:00:00.000Z"
);
const mainId = `agent_main_${id}`;
const subId = `agent_sub_${id}`;
db.prepare(
"INSERT INTO agents (id, session_id, name, type, status, started_at) VALUES (?,?,?,?,?,?)"
).run(mainId, id, "Main", "main", "completed", "2026-06-01T00:00:00.000Z");
// Child references parent — exercises deferred-FK ordering on restore.
db.prepare(
"INSERT INTO agents (id, session_id, name, type, subagent_type, status, started_at, parent_agent_id) VALUES (?,?,?,?,?,?,?,?)"
).run(subId, id, "Sub", "subagent", "explorer", "completed", "2026-06-01T00:10:00.000Z", mainId);
for (let i = 0; i < events; i++) {
db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, created_at) VALUES (?,?,?,?,?,?)"
).run(id, mainId, "PostToolUse", "Bash", `evt ${i}`, `2026-06-01T00:0${i}:00.000Z`);
}
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens, output_tokens, baseline_input) VALUES (?,?,?,?,?)"
).run(id, "claude-opus-4-8", 1000, 500, 250);
db.prepare(
"INSERT INTO workflows (run_id, session_id, name, status, agent_count, total_tokens) VALUES (?,?,?,?,?,?)"
).run(`wf_${id}`, id, "wf", "completed", 2, 1500);
}
describe("data-transfer export/import round-trip", () => {
before(() => {
// Config-like rows (independent of sessions).
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status) VALUES (?,?,?,?,?)"
).run("run_1", "S1", "headless", "/tmp/x", "completed");
db.prepare("INSERT INTO alert_rules (id, name, rule_type, config) VALUES (?,?,?,?)").run(
"rule_1",
"My Rule",
"inactivity",
"{}"
);
stmts.upsertPricing.run("custom-model-*", "Custom", 3, 15, 0.3, 3.75, 6, 0, 0);
seedSession("S1", { events: 3 });
seedSession("S2", { events: 2 });
});
it("exports every table with a format/version stamp", () => {
const bundle = buildExportBundle(db, stmts);
assert.equal(bundle.format, EXPORT_FORMAT);
assert.equal(bundle.version, EXPORT_VERSION);
assert.ok(bundle.exported_at);
assert.equal(bundle.sessions.length, 2);
assert.equal(bundle.agents.length, 4);
assert.equal(bundle.events.length, 5);
assert.equal(bundle.token_usage.length, 2);
assert.equal(bundle.workflows.length, 2);
assert.ok(bundle.dashboard_runs.some((r) => r.id === "run_1"));
assert.ok(bundle.alert_rules.some((r) => r.id === "rule_1"));
assert.ok(bundle.model_pricing.some((p) => p.model_pattern === "custom-model-*"));
});
it("restores an exported bundle accurately into a fresh DB", () => {
const bundle = JSON.parse(JSON.stringify(buildExportBundle(db, stmts)));
// Simulate a fresh machine: wipe everything the bundle carries.
db.exec(
"DELETE FROM events; DELETE FROM token_usage; DELETE FROM workflows; DELETE FROM agents; DELETE FROM sessions; DELETE FROM dashboard_runs; DELETE FROM alert_rules; DELETE FROM model_pricing;"
);
const c = importExportBundle(db, bundle);
assert.equal(c.sessions_imported, 2);
assert.equal(c.sessions_skipped, 0);
assert.equal(c.agents, 4);
assert.equal(c.events, 5);
assert.equal(c.token_usage, 2);
assert.equal(c.workflows, 2);
assert.equal(c.dashboard_runs, 1);
assert.equal(c.alert_rules, 1);
// model_pricing includes the seeded default rows plus our custom one; all
// are restored into the wiped DB.
assert.equal(c.model_pricing, bundle.model_pricing.length);
assert.ok(
db.prepare("SELECT 1 FROM model_pricing WHERE model_pattern = 'custom-model-*'").get()
);
// Accuracy: token totals (incl. baseline) restored verbatim.
const tu = db.prepare("SELECT * FROM token_usage WHERE session_id = 'S1'").get();
assert.equal(tu.input_tokens, 1000);
assert.equal(tu.baseline_input, 250);
// Parent/child agent link survived deferred-FK restore.
const sub = db.prepare("SELECT parent_agent_id FROM agents WHERE id = 'agent_sub_S1'").get();
assert.equal(sub.parent_agent_id, "agent_main_S1");
});
it("is idempotent — re-importing skips existing sessions, no duplicate events", () => {
const bundle = buildExportBundle(db, stmts);
const eventsBefore = db.prepare("SELECT COUNT(*) c FROM events").get().c;
const c = importExportBundle(db, bundle);
assert.equal(c.sessions_imported, 0);
assert.equal(c.sessions_skipped, 2);
assert.equal(c.events, 0);
const eventsAfter = db.prepare("SELECT COUNT(*) c FROM events").get().c;
assert.equal(eventsAfter, eventsBefore, "events must not be duplicated on re-import");
});
it("merges a second machine's sessions without touching existing ones", () => {
// Current DB has S1, S2. Build a bundle that adds a brand-new session S3.
const bundle = buildExportBundle(db, stmts);
seedSession("S3", { events: 4 });
const merged = buildExportBundle(db, stmts);
// Roll the seeded S3 back out so the DB looks like the "target" (S1,S2)
// and the bundle is the "source" (S1,S2,S3).
db.exec(
"DELETE FROM events WHERE session_id='S3'; DELETE FROM token_usage WHERE session_id='S3'; DELETE FROM workflows WHERE session_id='S3'; DELETE FROM agents WHERE session_id='S3'; DELETE FROM sessions WHERE id='S3';"
);
void bundle;
const c = importExportBundle(db, merged);
assert.equal(c.sessions_imported, 1, "only the new session imports");
assert.equal(c.sessions_skipped, 2, "existing sessions are skipped");
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions").get().c, 3);
assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='S3'").get().c, 4);
});
it("rejects a non-export object", () => {
assert.throws(() => importExportBundle(db, { foo: "bar" }), /recognizable dashboard export/);
assert.throws(() => importExportBundle(db, { format: "something-else" }), /Unrecognized/);
});
});
@@ -0,0 +1,485 @@
/**
* @file Tests for the first-user-prompt fallback descriptor (issue #201).
* Covers:
* - TranscriptCache capturing the first real user message (tool-result,
* meta/caveat, slash-command plumbing, and interrupt entries skipped),
* normalized and length-capped, first value winning across incremental
* re-reads.
* - The hook ingestor filling placeholder session/main-agent names and the
* main agent task from the descriptor — without clobbering real titles,
* user-set names, or an in-flight current_tool — and staying idempotent.
* - A later ai-title replacing a descriptor-filled session name.
* - importSession using the descriptor for imported sessions (new rows and
* the re-import backfill path).
* Uses Node's built-in test runner with temp CLAUDE_HOME / DASHBOARD_DATA_DIR.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const STAMP = `first-user-desc-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR = path.join(TMP, "data");
const TEST_DB = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = DATA_DIR;
const { createApp, startServer } = require("../index");
const dbModule = require("../db");
const { db, stmts } = dbModule;
const TranscriptCache = require("../lib/transcript-cache");
const { parseSessionFile, importSession } = require("../../scripts/import-history");
const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
const PROJECTS = path.join(CLAUDE_HOME, "projects");
function jsonl(lines) {
return lines.map((o) => JSON.stringify(o)).join("\n") + "\n";
}
function transcriptPath(cwd, sessionId) {
return path.join(PROJECTS, enc(cwd), `${sessionId}.jsonl`);
}
function writeTranscript(cwd, sessionId, lines) {
const p = transcriptPath(cwd, sessionId);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, jsonl(lines));
return p;
}
function appendTranscript(cwd, sessionId, lines) {
const p = transcriptPath(cwd, sessionId);
fs.appendFileSync(p, jsonl(lines));
return p;
}
function req(method, urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(b || "{}");
} catch {
parsed = b;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
r.on("error", reject);
if (payload) r.write(payload);
r.end();
});
}
let server;
let BASE;
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.rmSync(TMP, { recursive: true, force: true });
} catch {
/* ignore */
}
});
describe("TranscriptCache — first user message extraction", () => {
it("captures the first real prompt, skipping caveat/command/tool-result entries", () => {
const cwd = "/tmp/fud-cache-skip";
const sid = "cache-skip";
const p = writeTranscript(cwd, sid, [
{
type: "user",
isMeta: true,
message: {
role: "user",
content: "<local-command-caveat>Caveat: local commands</local-command-caveat>",
},
},
{
type: "user",
message: { role: "user", content: "<command-name>/model</command-name>" },
},
{
type: "user",
message: {
role: "user",
content: "<local-command-stdout>Set model</local-command-stdout>",
},
},
{
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "t1", content: "result text" }],
},
},
{
type: "user",
message: { role: "user", content: "fix the login\nbug in auth.ts" },
},
{ type: "user", message: { role: "user", content: "a later prompt" } },
]);
const r = new TranscriptCache().extract(p);
assert.ok(r, "result should not be null");
// Whitespace runs / newlines collapse to single spaces; first prompt wins.
assert.equal(r.firstUserMessage, "fix the login bug in auth.ts");
});
it("skips user-interrupt entries and caps the captured text at 500 chars", () => {
const cwd = "/tmp/fud-cache-cap";
const sid = "cache-cap";
const long = "x".repeat(600);
const p = writeTranscript(cwd, sid, [
{
type: "user",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
},
{ type: "user", message: { role: "user", content: long } },
]);
const r = new TranscriptCache().extract(p);
assert.equal(r.firstUserMessage, "x".repeat(500));
});
it("returns a result for a transcript that has ONLY a user prompt", () => {
const cwd = "/tmp/fud-cache-only";
const sid = "cache-only";
const p = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "just a prompt" } },
]);
const r = new TranscriptCache().extract(p);
assert.ok(r, "result should not be null");
assert.equal(r.firstUserMessage, "just a prompt");
});
it("keeps the FIRST prompt across incremental appends (first wins)", () => {
const cwd = "/tmp/fud-cache-incr";
const sid = "cache-incr";
const cache = new TranscriptCache();
const p = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "original prompt" } },
]);
assert.equal(cache.extract(p).firstUserMessage, "original prompt");
appendTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "second prompt" } },
]);
// Force a fresh stat (mtime granularity) by touching size — append above
// already grew the file, so the incremental path runs.
assert.equal(cache.extract(p).firstUserMessage, "original prompt");
});
});
describe("hook ingestor — descriptor fills placeholders", () => {
it("fills placeholder session name and main agent name/task on UserPromptSubmit", async () => {
const cwd = "/tmp/fud-hook-fill";
const sid = "10000000-0000-0000-0000-000000000001";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "add dark mode to settings" } },
]);
const res = await req("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(res.status, 200);
const sess = stmts.getSession.get(sid);
assert.equal(sess.name, "add dark mode to settings");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - add dark mode to settings");
assert.equal(main.task, "add dark mode to settings");
});
it("truncates long prompts to 60 chars for names but keeps the task longer", async () => {
const cwd = "/tmp/fud-hook-trunc";
const sid = "10000000-0000-0000-0000-000000000002";
const prompt = "p".repeat(100);
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: prompt } },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
const sess = stmts.getSession.get(sid);
assert.equal(sess.name, "p".repeat(57) + "...");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, `Main Agent - ${"p".repeat(57)}...`);
assert.equal(main.task, prompt);
});
it("title still wins: ai-title takes the session name, descriptor fills the agent", async () => {
const cwd = "/tmp/fud-hook-title";
const sid = "10000000-0000-0000-0000-000000000003";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "investigate the flaky test" } },
{ type: "ai-title", aiTitle: "Flaky test investigation", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
const sess = stmts.getSession.get(sid);
assert.equal(sess.name, "Flaky test investigation");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - investigate the flaky test");
assert.equal(main.task, "investigate the flaky test");
});
it("a later ai-title replaces a descriptor-filled session name", async () => {
const cwd = "/tmp/fud-hook-later-title";
const sid = "10000000-0000-0000-0000-000000000004";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "refactor the websocket layer" } },
]);
await req("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "refactor the websocket layer");
appendTranscript(cwd, sid, [
{ type: "ai-title", aiTitle: "WebSocket refactor", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "WebSocket refactor");
});
it("never clobbers a user-set session name or a renamed main agent", async () => {
const cwd = "/tmp/fud-hook-user-set";
const sid = "10000000-0000-0000-0000-000000000005";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "descriptor text" } },
]);
// Seed the session, then simulate user-chosen names on both rows.
await req("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: sid, cwd },
});
stmts.updateSessionName.run("my chosen name", sid, "my chosen name");
stmts.updateAgent.run("My Renamed Agent", null, "my task", null, null, null, `${sid}-main`);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "my chosen name");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "My Renamed Agent");
assert.equal(main.task, "my task");
});
it("preserves an in-flight current_tool when filling the main agent", async () => {
const cwd = "/tmp/fud-hook-tool";
const sid = "10000000-0000-0000-0000-000000000006";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "run the test suite" } },
]);
// PreToolUse stamps current_tool = Bash in the same transaction that then
// applies the descriptor — the fill must not wipe the in-flight tool.
await req("POST", "/api/hooks/event", {
hook_type: "PreToolUse",
data: { session_id: sid, cwd, transcript_path: tpath, tool_name: "Bash" },
});
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.current_tool, "Bash");
assert.equal(main.name, "Main Agent - run the test suite");
assert.equal(main.task, "run the test suite");
});
it("is idempotent — a second event changes nothing", async () => {
const cwd = "/tmp/fud-hook-idem";
const sid = "10000000-0000-0000-0000-000000000007";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "one prompt only" } },
]);
await req("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, cwd, transcript_path: tpath },
});
const before = {
session: stmts.getSession.get(sid).name,
agent: stmts.getAgent.get(`${sid}-main`),
};
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, before.session);
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, before.agent.name);
assert.equal(main.task, before.agent.task);
});
});
describe("import — descriptor for imported sessions", () => {
it("parseSessionFile falls back to the first user prompt below titles", async () => {
const cwd = "/tmp/fud-import-parse";
const sid = "20000000-0000-0000-0000-000000000001";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "build the CSV exporter" },
},
]);
const parsed = await parseSessionFile(p);
assert.equal(parsed.firstUserMessage, "build the CSV exporter");
assert.equal(parsed.name, "build the CSV exporter");
});
it("a real title still outranks the descriptor in parseSessionFile", async () => {
const cwd = "/tmp/fud-import-title";
const sid = "20000000-0000-0000-0000-000000000002";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "some prompt" },
},
{ type: "ai-title", aiTitle: "Real title", sessionId: sid },
]);
const parsed = await parseSessionFile(p);
assert.equal(parsed.name, "Real title");
assert.equal(parsed.firstUserMessage, "some prompt");
});
it("importSession names new rows and their main agent from the descriptor", async () => {
const cwd = "/tmp/fud-import-new";
const sid = "20000000-0000-0000-0000-000000000003";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "wire up the pricing table" },
},
]);
const parsed = await parseSessionFile(p);
importSession(dbModule, parsed);
const sess = stmts.getSession.get(sid);
assert.equal(sess.name, "wire up the pricing table");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - wire up the pricing table");
assert.equal(main.task, "wire up the pricing table");
});
it("re-import backfills placeholder-named rows created by earlier imports", async () => {
const cwd = "/tmp/fud-import-backfill";
const sid = "20000000-0000-0000-0000-000000000004";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "migrate the alerts schema" },
},
]);
// Simulate an old import: cwd-derived placeholder session + agent names.
// metadata.imported = true is what routes importSession to its backfill
// branch (hand-created sessions are never touched by re-imports).
const base = path.basename(cwd);
stmts.insertSession.run(
sid,
`${base} - ${sid.slice(0, 8)}`,
"completed",
cwd,
null,
JSON.stringify({ imported: true })
);
stmts.insertAgent.run(
`${sid}-main`,
sid,
`Main Agent - ${base} - ${sid.slice(0, 8)}`,
"main",
null,
"completed",
null,
null,
null
);
const parsed = await parseSessionFile(p);
const result = importSession(dbModule, parsed);
assert.equal(result.backfilled, true);
assert.equal(stmts.getSession.get(sid).name, "migrate the alerts schema");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - migrate the alerts schema");
assert.equal(main.task, "migrate the alerts schema");
});
it("re-import keeps user-picked names intact", async () => {
const cwd = "/tmp/fud-import-keep";
const sid = "20000000-0000-0000-0000-000000000005";
const p = writeTranscript(cwd, sid, [
{
type: "user",
cwd,
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "descriptor that must not apply" },
},
]);
stmts.insertSession.run(
sid,
"picked by hand",
"completed",
cwd,
null,
JSON.stringify({ imported: true })
);
stmts.insertAgent.run(
`${sid}-main`,
sid,
"Main Agent - picked by hand",
"main",
null,
"completed",
"hand-written task",
null,
null
);
const parsed = await parseSessionFile(p);
importSession(dbModule, parsed);
assert.equal(stmts.getSession.get(sid).name, "picked by hand");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.name, "Main Agent - picked by hand");
assert.equal(main.task, "hand-written task");
});
});
+109
View File
@@ -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)`
);
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* @file Import-pipeline correctness regressions.
*
* Covers three defects found in an import audit:
* 1. Duplicate subagent rows — importSession must NOT create `-subagent-N`
* rows from the main transcript's Agent blocks when subagent TRANSCRIPTS
* (parsedSubagents) exist, since those `-jsonl-` rows are authoritative.
* The fallback (no transcripts) must still create `-subagent-N` rows.
* 2. transcript_path must be persisted on import (was NULL for every imported
* session, breaking the abandon sweep / compaction scan / cost backfill).
* 3. classifyJsonl must treat the dynamic-workflow tree
* (subagents/workflows/<run>/agent-*.jsonl) as a subagent, not a top-level
* session.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `dashboard-import-correct-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const importHistory = require("../../scripts/import-history");
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// A parsed subagent as parseSubagentFile would return it.
function makeSubData(agentId, agentType) {
return {
agentId,
agentType,
task: "recon",
model: "claude-haiku-4-5-20251001",
startedAt: "2026-04-18T12:00:10.000Z",
endedAt: "2026-04-18T12:01:00.000Z",
userMessages: 1,
assistantMessages: 2,
tokensByModel: {},
toolNames: ["Bash", "Read"],
thinkingBlockCount: 0,
toolEvents: [],
spawnedChildren: [],
};
}
// A parsed session as parseSessionFile would return it, with one Agent tool_use
// block. `parsedSubagents` is attached by the caller (importFromDirectory), so
// we set it explicitly here.
function makeSession(sessionId, { withTranscripts, transcriptPath }) {
const agentTs = "2026-04-18T11:59:00.000Z"; // >tolerance before the sub's start
return {
sessionId,
name: "T",
customTitle: null,
aiTitle: null,
cwd: "/tmp/proj",
model: "claude-opus-4-8",
version: null,
slug: "slug",
gitBranch: null,
transcriptPath,
startedAt: "2026-04-18T11:58:00.000Z",
endedAt: "2026-04-18T12:01:00.000Z",
teams: [],
userMessages: 1,
assistantMessages: 1,
tokensByModel: {},
messageTimestamps: ["2026-04-18T12:01:00.000Z"],
toolUses: [
{
id: "toolu_agent1",
name: "Agent",
input: { subagent_type: "Explore", description: "do x", prompt: "p" },
timestamp: agentTs,
},
],
compactions: [],
apiErrors: [],
fileModifiedAt: 0,
turnDurations: [],
entrypoint: null,
permissionMode: null,
thinkingBlockCount: 0,
toolResultErrors: [],
usageExtras: { service_tiers: [], speeds: [], inference_geos: [] },
parsedSubagents: withTranscripts ? [makeSubData("aaaa0001", "Explore")] : [],
};
}
describe("import correctness", () => {
it("does NOT create duplicate -subagent-N rows when subagent transcripts exist", () => {
const SID = "11110000-0000-4000-8000-000000000001";
importHistory.importSession(
dbModule,
makeSession(SID, { withTranscripts: true, transcriptPath: `/tmp/proj/${SID}.jsonl` })
);
const subs = stmts.listAgentsBySession.all(SID).filter((a) => a.type === "subagent");
const nSubagentN = subs.filter((a) => a.id.includes("-subagent-")).length;
const nJsonl = subs.filter((a) => a.id.includes("-jsonl-")).length;
assert.equal(nJsonl, 1, "one authoritative -jsonl- row");
assert.equal(nSubagentN, 0, "no duplicate -subagent-N row from the Agent block");
});
it("still creates -subagent-N rows when there are NO subagent transcripts", () => {
const SID = "11110000-0000-4000-8000-000000000002";
importHistory.importSession(
dbModule,
makeSession(SID, { withTranscripts: false, transcriptPath: `/tmp/proj/${SID}.jsonl` })
);
const subs = stmts.listAgentsBySession.all(SID).filter((a) => a.type === "subagent");
assert.equal(subs.filter((a) => a.id.includes("-subagent-")).length, 1, "fallback row created");
});
it("persists transcript_path on import", () => {
const SID = "11110000-0000-4000-8000-000000000003";
const tp = `/tmp/proj/${SID}.jsonl`;
importHistory.importSession(
dbModule,
makeSession(SID, { withTranscripts: false, transcriptPath: tp })
);
assert.equal(stmts.getSession.get(SID).transcript_path, tp);
});
it("classifyJsonl treats the workflow subagent tree as a subagent, not a session", () => {
const base = "/x/.claude/projects/-Users-x/sid";
assert.equal(importHistory.classifyJsonl(`${base}/subagents/agent-1.jsonl`), "subagent");
assert.equal(
importHistory.classifyJsonl(`${base}/subagents/workflows/wf_abc/agent-2.jsonl`),
"subagent"
);
assert.equal(importHistory.classifyJsonl(`/x/.claude/projects/-Users-x/sid.jsonl`), "session");
});
});
@@ -0,0 +1,208 @@
/**
* @file Regression: the offline CLI import path must link Workflow-tool inner
* agents to their run.
*
* A Claude Code Workflow-tool run (dynamic workflow / fleet of sub-agents)
* emits NO hooks, so in a headless `claude -p` run, a CI job, or an HPC/cluster
* compute node its per-run journal is never ingested live. Before this fix the
* CLI import path (`ccam import rescan` → importAllSessions, `ccam import path`
* → importFromDirectory) never called the workflow-journal ingest, so the
* nested inner-agent transcripts under
* `<sid>/subagents/workflows/<runId>/agent-*.jsonl` were imported with
* `workflow_run_id = NULL` — orphaned from their run — leaving the workflow
* stuck showing 1 agent instead of N.
*
* These tests build a fixture session tree with a streaming
* `subagents/workflows/<runId>/journal.jsonl` plus a few `agent-*.jsonl`, run
* the CLI import path (never the server), and assert every inner agent ends up
* with `workflow_run_id = <runId>` and the workflows row reports the full fleet.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
// Scope the importer at a throwaway CLAUDE_HOME and DB before any server module
// loads — import-history captures PROJECTS_DIR from CLAUDE_HOME at require time.
const TMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), `ccam-wf-link-${process.pid}-`));
process.env.CLAUDE_HOME = TMP_ROOT;
process.env.DASHBOARD_DATA_DIR = path.join(TMP_ROOT, "data");
process.env.DASHBOARD_DB_PATH = path.join(TMP_ROOT, "dashboard.db");
const dbModule = require("../db");
const { importAllSessions, importFromDirectory } = require("../../scripts/import-history");
const PROJECTS_DIR = path.join(TMP_ROOT, "projects");
/** A minimal session transcript with one timestamped assistant turn. */
function sessionJsonl(cwd) {
return [
{
type: "user",
timestamp: "2026-05-01T00:00:00.000Z",
cwd,
message: { content: "run the workflow" },
},
{
type: "assistant",
timestamp: "2026-05-01T00:00:01.000Z",
cwd,
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "starting" }],
usage: {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
]
.map((l) => JSON.stringify(l))
.join("\n");
}
/** A parseable inner-agent transcript with token usage + one tool call. */
function agentJsonl(agentId) {
return [
{
type: "user",
timestamp: "2026-05-01T00:00:02.000Z",
message: { content: `task for ${agentId}` },
},
{
type: "assistant",
timestamp: "2026-05-01T00:00:03.000Z",
message: {
model: "claude-opus-4-8",
content: [{ type: "tool_use", id: `${agentId}-t1`, name: "Read", input: {} }],
usage: {
input_tokens: 100,
output_tokens: 20,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
]
.map((l) => JSON.stringify(l))
.join("\n");
}
/**
* Write a session tree with a Workflow-tool run to disk under PROJECTS_DIR:
* <projName>/<sid>.jsonl
* <projName>/<sid>/subagents/workflows/<runId>/journal.jsonl (streaming)
* <projName>/<sid>/subagents/workflows/<runId>/agent-<id>.jsonl (one per agent)
* Returns the project dir path.
*/
function writeFixture(projName, sid, runId, agentIds) {
const projDir = path.join(PROJECTS_DIR, projName);
const cwd = `/tmp/${projName}`;
fs.mkdirSync(projDir, { recursive: true });
fs.writeFileSync(path.join(projDir, `${sid}.jsonl`), sessionJsonl(cwd));
const runDir = path.join(projDir, sid, "subagents", "workflows", runId);
fs.mkdirSync(runDir, { recursive: true });
const journalLines = [];
for (const id of agentIds) {
fs.writeFileSync(path.join(runDir, `agent-${id}.jsonl`), agentJsonl(id));
journalLines.push(JSON.stringify({ type: "started", agentId: id }));
journalLines.push(JSON.stringify({ type: "result", agentId: id, result: "ok" }));
}
fs.writeFileSync(path.join(runDir, "journal.jsonl"), journalLines.join("\n"));
return projDir;
}
const linkedCount = (runId) =>
dbModule.db.prepare("SELECT COUNT(*) AS c FROM agents WHERE workflow_run_id = ?").get(runId).c;
const workflowRow = (runId) =>
dbModule.db.prepare("SELECT agent_count FROM workflows WHERE run_id = ?").get(runId);
const linkedIds = (runId) =>
dbModule.db
.prepare("SELECT id FROM agents WHERE workflow_run_id = ? ORDER BY id")
.all(runId)
.map((r) => r.id);
before(() => {
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
});
after(() => {
try {
dbModule.db.close();
} catch {
/* ignore */
}
try {
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
} catch {
/* ignore */
}
});
describe("offline CLI import links Workflow-tool inner agents", () => {
it("importFromDirectory (ccam import path) links every inner agent to its run", async () => {
const SID = "aaaa1111-0000-4000-8000-000000000001";
const RUN = "wf_dirimport01";
const AGENTS = ["d1", "d2", "d3"];
const projDir = writeFixture("-tmp-projA", SID, RUN, AGENTS);
assert.equal(linkedCount(RUN), 0, "no inner agents linked before import");
await importFromDirectory(dbModule, projDir);
assert.equal(linkedCount(RUN), AGENTS.length, "all inner agents linked to the run");
assert.equal(
workflowRow(RUN)?.agent_count,
AGENTS.length,
"workflows.agent_count reflects fleet"
);
assert.deepEqual(
linkedIds(RUN),
AGENTS.map((a) => `${SID}-jsonl-${a}`).sort(),
"each inner agent linked under its <sid>-jsonl-<agentId> id"
);
});
it("importAllSessions (ccam import rescan) links every inner agent to its run", async () => {
const SID = "bbbb2222-0000-4000-8000-000000000002";
const RUN = "wf_rescan02";
const AGENTS = ["r1", "r2", "r3", "r4"];
writeFixture("-tmp-projB", SID, RUN, AGENTS);
assert.equal(linkedCount(RUN), 0, "no inner agents linked before rescan");
await importAllSessions(dbModule);
assert.equal(linkedCount(RUN), AGENTS.length, "all inner agents linked to the run");
assert.equal(
workflowRow(RUN)?.agent_count,
AGENTS.length,
"workflows.agent_count reflects fleet"
);
});
it("re-running the import is idempotent — no duplicate links or rows", async () => {
const SID = "cccc3333-0000-4000-8000-000000000003";
const RUN = "wf_idem03";
const AGENTS = ["i1", "i2"];
const projDir = writeFixture("-tmp-projC", SID, RUN, AGENTS);
await importFromDirectory(dbModule, projDir);
await importFromDirectory(dbModule, projDir);
assert.equal(linkedCount(RUN), AGENTS.length, "still exactly N links after a second import");
// N inner agents + the one main agent for the session, nothing duplicated.
const total = dbModule.db
.prepare("SELECT COUNT(*) AS c FROM agents WHERE session_id = ?")
.get(SID).c;
assert.equal(total, AGENTS.length + 1, "no duplicate agent rows on re-import");
});
});
+553
View File
@@ -0,0 +1,553 @@
/**
* @file Tests for the Import History feature — the generalized directory
* importer and the /api/import routes. Verifies that token counts and cost
* computations come out identical between auto-import and manual import
* for the same JSONL fixtures, that re-imports are idempotent, and that
* archive extraction rejects path-traversal entries.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const zlib = require("zlib");
const TEST_DB = path.join(os.tmpdir(), `dashboard-import-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const importHistory = require("../../scripts/import-history");
const archive = require("../lib/archive");
let server;
let BASE;
function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed, headers: res.headers });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
function post(urlPath, body) {
return fetch(urlPath, { method: "POST", body });
}
// ────────────────────────────────────────────────────────────────────────────
// Fixtures — deterministic JSONL sessions with known token counts so we can
// assert imported values match byte-for-byte.
// ────────────────────────────────────────────────────────────────────────────
const SESSION_A = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa";
const SESSION_B = "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb";
function fixtureLines(sessionId, cwd, model, inputTok, outputTok) {
const base = "2026-04-18T12:00:00.000Z";
return [
{ type: "user", cwd, sessionId, timestamp: base, message: { content: "hi" } },
{
type: "assistant",
cwd,
sessionId,
timestamp: base,
message: {
model,
content: [{ type: "text", text: "ok" }],
usage: {
input_tokens: inputTok,
output_tokens: outputTok,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
{
type: "assistant",
cwd,
sessionId,
timestamp: "2026-04-18T12:00:01.000Z",
message: {
model,
content: [{ type: "tool_use", name: "Read", input: { file_path: "/tmp/foo" } }],
usage: {
input_tokens: inputTok,
output_tokens: outputTok,
cache_read_input_tokens: 10,
cache_creation_input_tokens: 20,
},
},
},
];
}
function writeFixtureDir() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-fixture-"));
const projDir = path.join(root, "-Users-demo-project");
fs.mkdirSync(projDir, { recursive: true });
fs.writeFileSync(
path.join(projDir, `${SESSION_A}.jsonl`),
fixtureLines(SESSION_A, "/Users/demo/project", "claude-opus-4-7", 100, 50)
.map((o) => JSON.stringify(o))
.join("\n")
);
fs.writeFileSync(
path.join(projDir, `${SESSION_B}.jsonl`),
fixtureLines(SESSION_B, "/Users/demo/project", "claude-sonnet-4-6", 200, 100)
.map((o) => JSON.stringify(o))
.join("\n")
);
return root;
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// ────────────────────────────────────────────────────────────────────────────
describe("GET /api/import/guide", () => {
it("returns OS-aware instructions and supported extensions", async () => {
const res = await fetch("/api/import/guide");
assert.equal(res.status, 200);
assert.equal(typeof res.body.default_projects_dir, "string");
assert.ok(res.body.supported_extensions.includes(".jsonl"));
assert.ok(res.body.supported_extensions.includes(".tar.gz"));
assert.ok(res.body.supported_extensions.includes(".zip"));
assert.ok(Array.isArray(res.body.steps));
assert.ok(res.body.steps.length >= 4);
assert.ok(res.body.archive_command.includes("tar"));
});
});
describe("POST /api/import/scan-path validation", () => {
it("rejects missing path", async () => {
const res = await post("/api/import/scan-path", {});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
});
it("rejects relative paths", async () => {
const res = await post("/api/import/scan-path", { path: "./somewhere" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
});
it("rejects non-existent paths", async () => {
const res = await post("/api/import/scan-path", {
path: "/definitely/does/not/exist/ccam-" + Date.now(),
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "PATH_NOT_FOUND");
});
it("rejects files (not directories)", async () => {
const tmp = path.join(os.tmpdir(), `ccam-not-dir-${Date.now()}.txt`);
fs.writeFileSync(tmp, "hello");
try {
const res = await post("/api/import/scan-path", { path: tmp });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "NOT_A_DIRECTORY");
} finally {
fs.unlinkSync(tmp);
}
});
});
describe("POST /api/import/scan-path happy path", () => {
it("imports sessions from a custom folder and records token usage", async () => {
const root = writeFixtureDir();
try {
const res = await post("/api/import/scan-path", { path: root });
assert.equal(res.status, 200);
assert.ok(res.body.ok);
// Both sessions should import the first time.
assert.ok(res.body.imported >= 2);
assert.equal(res.body.errors, 0);
const sessA = stmts.getSession.get(SESSION_A);
const sessB = stmts.getSession.get(SESSION_B);
assert.ok(sessA, "session A should exist in DB");
assert.ok(sessB, "session B should exist in DB");
// Tokens: each fixture has 2 assistant messages with usage.
const tokA = stmts.getTokensBySession.all(SESSION_A);
const opus = tokA.find((t) => /opus/.test(t.model));
assert.ok(opus, "expected opus tokens");
assert.equal(opus.input_tokens, 200);
assert.equal(opus.output_tokens, 100);
assert.equal(opus.cache_read_tokens, 10);
assert.equal(opus.cache_write_tokens, 20);
// Cost endpoint should produce a non-zero result after we add a pricing rule.
const ruleRes = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: "claude-opus-4-7",
display_name: "Opus 4.7",
input_per_mtok: 15,
output_per_mtok: 75,
cache_read_per_mtok: 1.5,
cache_write_per_mtok: 18.75,
},
});
assert.equal(ruleRes.status, 200);
const costRes = await fetch(`/api/pricing/cost/${SESSION_A}`);
assert.equal(costRes.status, 200);
assert.ok(costRes.body.total_cost > 0);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("is idempotent: a second scan does not duplicate tokens", async () => {
const before = stmts.getTokensBySession.all(SESSION_A);
const root = writeFixtureDir();
try {
const res = await post("/api/import/scan-path", { path: root });
assert.equal(res.status, 200);
const after = stmts.getTokensBySession.all(SESSION_A);
const beforeOpus = before.find((t) => /opus/.test(t.model));
const afterOpus = after.find((t) => /opus/.test(t.model));
assert.equal(afterOpus.input_tokens, beforeOpus.input_tokens);
assert.equal(afterOpus.output_tokens, beforeOpus.output_tokens);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
describe("archive helpers", () => {
it("isPathInside rejects traversal", () => {
const root = path.resolve("/tmp/ccam-root");
assert.equal(archive.isPathInside(root, "/tmp/ccam-root/ok.jsonl"), true);
assert.equal(archive.isPathInside(root, "/tmp/other/bad.jsonl"), false);
assert.equal(archive.isPathInside(root, "/tmp/ccam-root/../escape"), false);
});
it("safeJoin rejects absolute and traversal entries", () => {
const root = path.resolve("/tmp/ccam-root");
assert.equal(archive.safeJoin(root, "/etc/passwd"), path.join(root, "etc/passwd"));
assert.equal(archive.safeJoin(root, "../escape.txt"), null);
assert.equal(archive.safeJoin(root, "deep/../../escape"), null);
assert.ok(archive.safeJoin(root, "good/file.jsonl").startsWith(root));
});
it("detectKind handles common extensions", () => {
assert.equal(archive.detectKind("a.jsonl"), "jsonl");
assert.equal(archive.detectKind("a.meta.json"), "meta");
assert.equal(archive.detectKind("a.zip"), "zip");
assert.equal(archive.detectKind("a.tar"), "tar");
assert.equal(archive.detectKind("a.tar.gz"), "tgz");
assert.equal(archive.detectKind("a.tgz"), "tgz");
assert.equal(archive.detectKind("a.gz"), "gz");
assert.equal(archive.detectKind("random.bin"), "unknown");
});
it("extractGzSingle decompresses plain gz", async () => {
const dest = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gz-"));
const src = path.join(dest, "sample.jsonl.gz");
fs.writeFileSync(src, zlib.gzipSync(Buffer.from('{"ok":true}\n')));
try {
const result = await archive.extractGzSingle(src, dest);
assert.equal(result.extracted, 1);
assert.ok(fs.existsSync(path.join(dest, "sample.jsonl")));
} finally {
fs.rmSync(dest, { recursive: true, force: true });
}
});
});
describe("importFromDirectory directly", () => {
it("reports progress and never throws on empty dirs", async () => {
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-empty-"));
try {
const events = [];
const counters = await importHistory.importFromDirectory({ db, stmts }, empty, {
onProgress: (p) => events.push(p.phase),
});
assert.equal(counters.filesScanned, 0);
assert.ok(events.includes("complete"));
} finally {
fs.rmSync(empty, { recursive: true, force: true });
}
});
it("matches the legacy importer's token totals on the same fixtures", async () => {
// Clean any tokens from prior tests for a fresh comparison.
const freshSession = "cccccccc-3333-4333-8333-cccccccccccc";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-fresh-"));
const projDir = path.join(root, "-Users-demo-fresh");
fs.mkdirSync(projDir, { recursive: true });
fs.writeFileSync(
path.join(projDir, `${freshSession}.jsonl`),
fixtureLines(freshSession, "/Users/demo/fresh", "claude-haiku-4-5", 7, 3)
.map((o) => JSON.stringify(o))
.join("\n")
);
try {
await importHistory.importFromDirectory({ db, stmts }, root);
const tok = stmts.getTokensBySession.all(freshSession);
const haiku = tok.find((t) => /haiku/.test(t.model));
assert.ok(haiku);
assert.equal(haiku.input_tokens, 14); // 7 * 2 messages
assert.equal(haiku.output_tokens, 6); // 3 * 2 messages
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("snapshots the transcript into the data dir so it survives Claude Code pruning", async () => {
// Regression: the Conversation tab reads JSONL live from ~/.claude/projects,
// but Claude Code deletes session files older than cleanupPeriodDays
// (default 30 days). Import must snapshot the transcript into the
// dashboard's own data dir so the conversation survives that deletion.
const prevDataDir = process.env.DASHBOARD_DATA_DIR;
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-data-"));
process.env.DASHBOARD_DATA_DIR = dataDir;
const src = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-src-"));
const sessionId = "dddddddd-4444-4444-8444-dddddddddddd";
fs.writeFileSync(
path.join(src, `${sessionId}.jsonl`),
fixtureLines(sessionId, "/Users/demo/snap", "claude-opus-4-8", 5, 3)
.map((o) => JSON.stringify(o))
.join("\n")
);
try {
await importHistory.importFromDirectory({ db, stmts }, src);
const snapshot = path.join(dataDir, "transcripts", `${sessionId}.jsonl`);
assert.ok(fs.existsSync(snapshot), "transcript should be snapshotted into the data dir");
assert.ok(
fs.readFileSync(snapshot, "utf8").includes('"text":"ok"'),
"snapshot should contain the original conversation"
);
// And the read route should resolve it via the snapshot helper.
const { getSnapshotTranscriptPath } = require("../lib/claude-home");
assert.equal(getSnapshotTranscriptPath(sessionId), snapshot);
} finally {
if (prevDataDir === undefined) delete process.env.DASHBOARD_DATA_DIR;
else process.env.DASHBOARD_DATA_DIR = prevDataDir;
fs.rmSync(src, { recursive: true, force: true });
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
});
describe("POST /api/import/rescan", () => {
it("runs without crashing even when default projects dir is missing", async () => {
// We can't mutate the real projects dir, but we can assert the endpoint
// always returns a JSON envelope regardless of whether it found anything.
const res = await post("/api/import/rescan");
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(typeof res.body.imported, "number");
assert.equal(typeof res.body.skipped, "number");
});
});
// ────────────────────────────────────────────────────────────────────────────
// Hardening tests
// ────────────────────────────────────────────────────────────────────────────
describe("tar path-traversal hardening", () => {
it("extractTar rejects entries with ../ segments", async () => {
const tar = require("tar");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-tar-bad-"));
const stageDir = path.join(tmp, "stage");
const targetDir = path.join(tmp, "target");
fs.mkdirSync(stageDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
// Build a tar that tries to write "../escape.jsonl".
const inner = path.join(stageDir, "legit.jsonl");
fs.writeFileSync(inner, '{"ok":true}\n');
const tarPath = path.join(tmp, "evil.tar");
await tar.c({ file: tarPath, cwd: stageDir, prefix: "../" }, ["legit.jsonl"]);
try {
await archive.extractTar(tarPath, targetDir);
// Anything extracted must remain inside targetDir.
const walked = [];
(function walk(d) {
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, ent.name);
if (ent.isDirectory()) walk(p);
else walked.push(p);
}
})(targetDir);
for (const p of walked) {
assert.ok(
path.resolve(p).startsWith(path.resolve(targetDir) + path.sep) ||
path.resolve(p) === path.resolve(targetDir),
`traversal escape detected: ${p}`
);
}
// Nothing should exist one level above targetDir with name escape.jsonl
assert.equal(fs.existsSync(path.join(tmp, "escape.jsonl")), false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe("extraction size cap", () => {
it("extractGzSingle aborts past MAX_EXTRACT_BYTES", async () => {
const prev = process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES;
process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES = "128";
// Re-require to pick up the lowered limit for this one check.
delete require.cache[require.resolve("../lib/archive")];
const localArchive = require("../lib/archive");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-bomb-"));
const gzPath = path.join(tmp, "bomb.jsonl.gz");
// 2 KB of zeros compresses to a few bytes — decompressing blows past 128 B.
fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(2048, 0)));
try {
await assert.rejects(
() => localArchive.extractGzSingle(gzPath, tmp),
(err) => err.code === "EXTRACTION_LIMIT_EXCEEDED"
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
if (prev === undefined) delete process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES;
else process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES = prev;
// Restore the module with production limits for subsequent tests.
delete require.cache[require.resolve("../lib/archive")];
require("../lib/archive");
}
});
});
describe("orphan subagent inference", () => {
it("attaches subagent via Layout 2: <proj>/subagents/<sessionId>/agent.jsonl", async () => {
const orphanSession = "dddddddd-4444-4444-8444-dddddddddddd";
// Seed a parent session first.
const seedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-orphan-seed-"));
const seedProj = path.join(seedRoot, "project");
fs.mkdirSync(seedProj, { recursive: true });
fs.writeFileSync(
path.join(seedProj, `${orphanSession}.jsonl`),
fixtureLines(orphanSession, "/Users/demo/orphan", "claude-opus-4-7", 5, 5)
.map((o) => JSON.stringify(o))
.join("\n")
);
await importHistory.importFromDirectory({ db, stmts }, seedRoot);
assert.ok(stmts.getSession.get(orphanSession), "parent session must exist before orphan pass");
// Now create an "orphan" subagent tree in the non-standard layout.
const orphanRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-orphan-"));
const orphanLayoutDir = path.join(orphanRoot, "project", "subagents", orphanSession);
fs.mkdirSync(orphanLayoutDir, { recursive: true });
const subAgentId = "agent-xyz";
fs.writeFileSync(
path.join(orphanLayoutDir, `${subAgentId}.jsonl`),
[
{ type: "user", timestamp: "2026-04-18T12:00:00.000Z", message: { content: "hi" } },
{
type: "assistant",
timestamp: "2026-04-18T12:00:00.000Z",
message: {
model: "claude-opus-4-7",
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: 1, output_tokens: 1 },
},
},
]
.map((o) => JSON.stringify(o))
.join("\n")
);
try {
const before = db
.prepare("SELECT COUNT(*) as c FROM agents WHERE session_id = ?")
.get(orphanSession).c;
await importHistory.importFromDirectory({ db, stmts }, orphanRoot);
const after = db
.prepare("SELECT COUNT(*) as c FROM agents WHERE session_id = ?")
.get(orphanSession).c;
assert.ok(after > before, "orphan subagent should attach under known session");
} finally {
fs.rmSync(seedRoot, { recursive: true, force: true });
fs.rmSync(orphanRoot, { recursive: true, force: true });
}
});
});
describe("concurrent scan-path requests", () => {
it("two concurrent imports of different folders both succeed without clobbering", async () => {
const sessA = "eeeeeeee-5555-4555-8555-eeeeeeeeeeee";
const sessB = "ffffffff-6666-4666-8666-ffffffffffff";
const rootA = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-concurrent-a-"));
const rootB = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-concurrent-b-"));
fs.mkdirSync(path.join(rootA, "-Users-demo-a"), { recursive: true });
fs.mkdirSync(path.join(rootB, "-Users-demo-b"), { recursive: true });
fs.writeFileSync(
path.join(rootA, "-Users-demo-a", `${sessA}.jsonl`),
fixtureLines(sessA, "/Users/demo/a", "claude-opus-4-7", 3, 2)
.map((o) => JSON.stringify(o))
.join("\n")
);
fs.writeFileSync(
path.join(rootB, "-Users-demo-b", `${sessB}.jsonl`),
fixtureLines(sessB, "/Users/demo/b", "claude-sonnet-4-6", 4, 1)
.map((o) => JSON.stringify(o))
.join("\n")
);
try {
const [rA, rB] = await Promise.all([
post("/api/import/scan-path", { path: rootA }),
post("/api/import/scan-path", { path: rootB }),
]);
assert.equal(rA.status, 200);
assert.equal(rB.status, 200);
assert.ok(stmts.getSession.get(sessA), "session A should be imported");
assert.ok(stmts.getSession.get(sessB), "session B should be imported");
} finally {
fs.rmSync(rootA, { recursive: true, force: true });
fs.rmSync(rootB, { recursive: true, force: true });
}
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* @file Tests the host-only guard in scripts/install-hooks.js (issue #193):
* the installer must refuse to write a container-internal handler path into a
* (possibly bind-mounted) host ~/.claude/settings.json. Container detection is
* driven deterministically via CCAM_FORCE_CONTAINER / CCAM_FORCE_HOST so these
* tests pass whether or not the CI runner itself is containerized.
* @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("fs");
const os = require("os");
const path = require("path");
// Point the installer at a throwaway CLAUDE_HOME BEFORE requiring it — the
// settings path is resolved at module load. (`node --test` isolates each test
// file in its own process, so this does not leak into other suites.)
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-hooks-"));
process.env.CLAUDE_HOME = TMP_HOME;
const SETTINGS = path.join(TMP_HOME, "settings.json");
const { installHooks, isInsideContainer } = require("../../scripts/install-hooks");
const HOOK_TYPES = [
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"Notification",
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
];
function clearEnv() {
delete process.env.CCAM_FORCE_CONTAINER;
delete process.env.CCAM_FORCE_HOST;
delete process.env.CCAM_ALLOW_CONTAINER_HOOKS;
}
function rmSettings() {
try {
fs.unlinkSync(SETTINGS);
} catch {
/* not present */
}
}
describe("install-hooks host-only guard (#193)", () => {
beforeEach(() => {
clearEnv();
rmSettings();
});
after(() => {
clearEnv();
try {
fs.rmSync(TMP_HOME, { recursive: true, force: true });
} catch {
/* best effort */
}
});
it("refuses inside a container and writes no settings file", () => {
process.env.CCAM_FORCE_CONTAINER = "1";
const ok = installHooks(true);
assert.equal(ok, false);
assert.equal(
fs.existsSync(SETTINGS),
false,
"settings.json must not be created in a container"
);
});
it("writes when the explicit container override is set", () => {
process.env.CCAM_FORCE_CONTAINER = "1";
process.env.CCAM_ALLOW_CONTAINER_HOOKS = "1";
const ok = installHooks(true);
assert.equal(ok, true);
assert.ok(fs.existsSync(SETTINGS));
const settings = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
for (const type of HOOK_TYPES) {
assert.ok(Array.isArray(settings.hooks[type]), `missing hook list for ${type}`);
assert.match(JSON.stringify(settings.hooks[type]), /hook-handler\.js/, `${type} not wired`);
}
});
it("writes on a host (not a container)", () => {
process.env.CCAM_FORCE_HOST = "1";
const ok = installHooks(true);
assert.equal(ok, true);
assert.ok(fs.existsSync(SETTINGS));
});
it("is idempotent — re-running updates in place with no duplicate entries", () => {
process.env.CCAM_FORCE_HOST = "1";
installHooks(true);
installHooks(true);
const settings = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
const ours = settings.hooks.PreToolUse.filter((e) =>
JSON.stringify(e).includes("hook-handler.js")
);
assert.equal(ours.length, 1, "must not duplicate our hook entry on re-run");
});
it("isInsideContainer honors the force flags", () => {
process.env.CCAM_FORCE_CONTAINER = "1";
assert.equal(isInsideContainer(), true);
delete process.env.CCAM_FORCE_CONTAINER;
process.env.CCAM_FORCE_HOST = "1";
assert.equal(isInsideContainer(), false);
});
});
File diff suppressed because it is too large Load Diff
+669
View File
@@ -0,0 +1,669 @@
/**
* @file HTTP tests for /api/lanes: CRUD, stage reporting, the aggregate
* counters the header badges read, and the last-event age that drives liveness.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
const { WebSocket } = require("ws");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function request(method, urlPath, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: {
...headers,
...(payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {}),
},
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try {
parsed = JSON.parse(data);
} catch {
/* non-JSON body */
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => server && server.close());
describe("/api/lanes", () => {
let laneId;
it("creates a lane", async () => {
const r = await request("POST", "/api/lanes", { title: "Lane A", cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.title, "Lane A");
assert.equal(r.body.lane.stage, "idle");
laneId = r.body.lane.id;
});
it("rejects a relative cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "relative/path" });
assert.equal(r.status, 400);
});
it("rejects a duplicate cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 409);
});
it("reports a stage and returns node states", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, {
stage: "review",
status: "running",
evidence: null,
});
assert.equal(r.status, 200);
const review = r.body.lane.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "current");
assert.ok(r.body.lane.progress > 0);
});
it("404s on an unknown lane", async () => {
const r = await request("POST", "/api/lanes/99999/stage", { stage: "plan" });
assert.equal(r.status, 404);
});
it("lists lanes with counters", async () => {
const r = await request("GET", "/api/lanes");
assert.equal(r.status, 200);
assert.ok(r.body.lanes.length >= 1);
assert.equal(r.body.counts.total, r.body.lanes.length);
assert.equal(typeof r.body.counts.running, "number");
assert.equal(typeof r.body.counts.needs_you, "number");
});
it("exposes pipeline templates", async () => {
const r = await request("GET", "/api/lanes/pipelines");
assert.equal(r.status, 200);
assert.ok(r.body.pipelines.some((p) => p.id === "default"));
});
it("patches and deletes", async () => {
const p = await request("PATCH", `/api/lanes/${laneId}`, { ci_status: "green" });
assert.equal(p.body.lane.ci_status, "green");
const d = await request("DELETE", `/api/lanes/${laneId}`);
assert.equal(d.status, 200);
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
});
it("rejects cross-origin lane deletion", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
const id = created.body.lane.id;
const rejected = await request("DELETE", `/api/lanes/${id}`, undefined, {
Origin: "https://attacker.example",
});
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 200);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane patching", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-patch" });
const id = created.body.lane.id;
const rejected = await request(
"PATCH",
`/api/lanes/${id}`,
{ run_id: "attacker-run" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.run_id, null);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane creation", async () => {
const rejected = await request(
"POST",
"/api/lanes",
{ cwd: "/tmp/lane-cross-origin-create" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
});
it("rejects cross-origin stage reporting", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-stage" });
const id = created.body.lane.id;
const rejected = await request(
"POST",
`/api/lanes/${id}/stage`,
{ stage: "implement" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.stage, "idle");
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("broadcasts lane_update on create", async () => {
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
const ws = new WebSocket(wsUrl);
const messages = [];
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error("ws test timeout")), 5000);
});
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
Promise.race([timeout]).catch(reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
const createResp = await request("POST", "/api/lanes", {
title: "WS Test Lane",
cwd: "/tmp/ws-test-lane",
});
assert.equal(createResp.status, 201);
const newLaneId = createResp.body.lane.id;
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const laneUpdateMsg = messages.find(
(m) => m.type === "lane_update" && m.data.lane?.id === newLaneId
);
assert.ok(laneUpdateMsg, "create should broadcast lane_update");
assert.equal(laneUpdateMsg.data.lane.id, newLaneId);
assert.equal(laneUpdateMsg.data.lane.title, "WS Test Lane");
// Test delete broadcast
messages.length = 0;
const deleteResp = await request("DELETE", `/api/lanes/${newLaneId}`);
assert.equal(deleteResp.status, 200);
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const deleteMsg = messages.find(
(m) => m.type === "lane_update" && m.data.removed === newLaneId
);
assert.ok(deleteMsg, "delete should broadcast lane_update with removed");
assert.equal(deleteMsg.data.removed, newLaneId);
ws.close();
});
});
describe("hook → lane binding", () => {
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-hook-a",
title: "Hooked",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a/sub/dir" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-lane-1");
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", message: "needs permission" },
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("ignores a hook whose cwd is under no lane", async () => {
const before = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-orphan", cwd: "/tmp/not-a-lane" },
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
});
it("clears needs_action only from the session that raised it, not from cross-session rebinds", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-cross-session",
title: "Cross-Session",
});
const id = created.body.lane.id;
// Session A sets needs_action
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session", message: "blocked" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-a");
// Session B PostToolUse: rebinds lane to B, but does NOT clear A's flag
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
// Session B's second PostToolUse clears the flag (B is now bound)
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("raises needs_action with default 'needs you' when Notification has no message", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-default-msg",
title: "Default Msg",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "needs you");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("hook → stage detection", () => {
it("a Bash test-run hook sets detected_stage to tests, a following Read leaves it unchanged", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-a",
title: "Detect",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
let lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Read",
tool_input: { file_path: "/tmp/lane-detect-a/foo.js" },
},
});
lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("a hook whose cwd is under no lane changes nothing", async () => {
const beforeLanes = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-orphan",
cwd: "/tmp/not-a-lane-detect",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, beforeLanes);
});
it("a lane already declared at ship ignores an implement detection", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-declared",
title: "Declared",
});
const id = created.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "ship", status: "running" });
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-2",
cwd: "/tmp/lane-detect-declared",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-declared/foo.js" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("a malformed hook payload still returns 200 and leaves the lane untouched", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-malformed",
title: "Malformed",
});
const id = created.body.lane.id;
const r1 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-3",
cwd: "/tmp/lane-detect-malformed",
tool_input: "just a string, no tool_name",
},
});
assert.equal(r1.status, 200);
const r2 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-detect-4", cwd: "/tmp/lane-detect-malformed" },
});
assert.equal(r2.status, 200);
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("detection is visible in GET /api/lanes/:id with a detected node, and no node is 'done'", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-visible",
title: "Visible",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-5",
cwd: "/tmp/lane-detect-visible",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
const testsNode = lane.pipeline_nodes.find((n) => n.id === "tests");
assert.equal(testsNode.detected, true);
assert.ok(!lane.pipeline_nodes.some((n) => n.state === "done"));
await request("DELETE", `/api/lanes/${id}`);
});
it("a throwing recordDetection does not cost the lane bookkeeping in the same hook", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-throws",
title: "Throws",
});
const id = created.body.lane.id;
// Bind the session and raise needs_action, exactly as a permission prompt does.
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-detect-throw", cwd: "/tmp/lane-detect-throws" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
message: "needs permission",
},
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
// Stand in for the real failures recordDetection can raise mid-hook: ENOLANE
// when the lane is deleted between resolveLaneByCwd and its lookup, or
// SQLITE_BUSY from another process on the same database.
const lanesLib = require("../lib/lanes");
const realRecordDetection = lanesLib.recordDetection;
lanesLib.recordDetection = () => {
throw Object.assign(new Error("no lane 999"), { code: "ENOLANE" });
};
try {
const r = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-throws/foo.js" },
},
});
assert.equal(r.status, 200);
} finally {
lanesLib.recordDetection = realRecordDetection;
}
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.needs_action, null, "bookkeeping must survive a failed detection");
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("lane actions", () => {
it("requires confirmation, then removes an adopted lane row without its directory", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-a" });
const id = c.body.lane.id;
assert.equal((await request("POST", `/api/lanes/${id}/remove`, {})).status, 400);
const preflight = await request("GET", `/api/lanes/${id}/preflight?action=remove`);
const removed = await request("POST", `/api/lanes/${id}/remove`, {
confirm: true,
expect: {
head: preflight.body.head,
dirty: preflight.body.dirty,
untracked: preflight.body.untracked,
unpushed: preflight.body.unpushed,
},
});
assert.equal(removed.status, 200);
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 404);
});
it("rejects an unknown action", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-b" });
const id = c.body.lane.id;
const before = await request("GET", `/api/lanes/${id}`);
const r = await request("POST", `/api/lanes/${id}/frobnicate`, {});
assert.equal(r.status, 400);
const after = await request("GET", `/api/lanes/${id}`);
assert.equal(after.body.lane.stage, before.body.lane.stage);
assert.equal(after.body.lane.status, before.body.lane.status);
await request("DELETE", `/api/lanes/${id}`);
});
it("clear resets stage state but keeps the lane", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-c", title: "Keep me" });
const id = c.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "review", status: "running" });
const r = await request("POST", `/api/lanes/${id}/clear`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.stage, "idle");
assert.equal(r.body.lane.title, "Keep me");
assert.deepEqual(r.body.lane.stages, {});
await request("DELETE", `/api/lanes/${id}`);
});
it("stop on a lane with no run is a no-op, not a 500", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-d" });
const r = await request("POST", `/api/lanes/${c.body.lane.id}/stop`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.status, "idle");
await request("DELETE", `/api/lanes/${c.body.lane.id}`);
});
it("message on a lane with a recorded-but-not-live run returns 409", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" });
const id = c.body.lane.id;
// Patch the lane with a bogus run_id (never existed, so not live).
await request("PATCH", `/api/lanes/${id}`, { run_id: "nonexistent-run" });
const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ENORUN");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("GET /api/lanes/:id/git", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-api-git-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("reports the working-copy facts for a lane pointing at a real repo", async () => {
const dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "feat/api-facts");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "one\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "api facts fixture");
fs.writeFileSync(path.join(dir, "a.txt"), "two\n");
fs.writeFileSync(path.join(dir, "untracked.txt"), "u\n");
const created = await request("POST", "/api/lanes", { cwd: dir, title: "git facts" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.branch, "feat/api-facts");
assert.equal(r.body.subject, "api facts fixture");
assert.equal(r.body.head, g(dir, "rev-parse", "--short", "HEAD").trim());
assert.equal(r.body.dirty, 1);
assert.equal(r.body.untracked, 1);
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false for a plain directory, not an error", async () => {
const dir = path.join(ROOT, "plain");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "plain dir" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false when the lane's directory is gone", async () => {
const dir = path.join(ROOT, "vanishes");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "vanishing" });
const id = created.body.lane.id;
fs.rmSync(dir, { recursive: true, force: true });
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("404s for a lane that does not exist", async () => {
const r = await request("GET", "/api/lanes/999999/git");
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOLANE");
});
it("resolves to the facts route, not an action error", async () => {
// The `/:id/:action` catch-all is a POST and cannot shadow this GET, so
// this pins the response SHAPE rather than any registration order.
const dir = path.join(ROOT, "ordering");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "ordering" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.ok("available" in r.body, JSON.stringify(r.body));
assert.equal(r.body.error, undefined);
await request("DELETE", `/api/lanes/${id}`);
});
});
+474
View File
@@ -0,0 +1,474 @@
/**
* @file Tests for the `ccam stage` / `ccam lanes` CLI subcommands: lane
* resolution from the current directory, the stage round-trip through the HTTP
* API, and the non-zero exit when the cwd belongs to no lane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const fs = require("fs");
const http = require("http");
const { spawn } = require("child_process");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-cli-${Date.now()}-${process.pid}.db`);
const PIPELINE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-pipeline-fixture-"));
const LANES_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-managed-lanes-cli-"));
const SOURCE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-source-"));
const REMOTE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-remote-"));
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_PIPELINES_DIR = PIPELINE_DIR;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.LANES_ROOT = LANES_ROOT;
// Create a fixture pipeline template for testing custom pipeline selection
fs.writeFileSync(
path.join(PIPELINE_DIR, "test-pipeline.json"),
JSON.stringify({
id: "test-pipeline",
name: "Test Pipeline",
nodes: [
{ id: "start", label: "start", icon: "🚀", gate: false, aliases: [] },
{ id: "end", label: "end", icon: "✓", gate: false, aliases: [] },
],
})
);
const { createApp, startServer } = require("../index");
const LANE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-cli-"));
const CLI = path.join(__dirname, "..", "..", "bin", "ccam.js");
let server;
let BASE;
function git(args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn("git", args, { cwd });
let stderr = "";
child.stderr.on("data", (chunk) => (stderr += chunk));
child.on("error", reject);
child.on("close", (status) => {
if (status === 0) return resolve();
reject(new Error(`git ${args.join(" ")} failed (${status}): ${stderr}`));
});
});
}
function post(urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = JSON.stringify(body);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
let d = "";
res.on("data", (c) => (d += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(d || "{}") }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
function get(urlPath) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
method: "GET",
},
(res) => {
let d = "";
res.on("data", (c) => (d += c));
res.on("end", () => resolve({ status: res.statusCode, body: d ? JSON.parse(d) : null }));
}
);
req.on("error", reject);
req.end();
});
}
// MUST be async: the test server runs in THIS process, so a blocking
// spawnSync would stall the event loop and the CLI child's request to
// 127.0.0.1 would never be served — a deadlock that looks like a network
// sandbox blocking loopback.
function cli(args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI, ...args], {
cwd,
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port) },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => (stdout += c));
child.stderr.on("data", (c) => (stderr += c));
child.on("error", reject);
child.on("close", (status) => resolve({ status, stdout, stderr }));
});
}
before(async () => {
await git(["init", "--bare"], REMOTE_REPO);
await git(["init", "--initial-branch=main"], SOURCE_REPO);
await git(["config", "user.email", "lanes-cli@example.test"], SOURCE_REPO);
await git(["config", "user.name", "Lanes CLI Test"], SOURCE_REPO);
fs.writeFileSync(path.join(SOURCE_REPO, "README.md"), "fixture\n");
await git(["add", "README.md"], SOURCE_REPO);
await git(["commit", "-m", "fixture"], SOURCE_REPO);
await git(["remote", "add", "origin", REMOTE_REPO], SOURCE_REPO);
await git(["push", "-u", "origin", "main"], SOURCE_REPO);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
// Poll for server readiness
let ready = false;
const deadline = Date.now() + 2000; // 2 second timeout
while (!ready && Date.now() < deadline) {
try {
const res = await get("/api/health");
if (res.status === 200) ready = true;
} catch {
/* not ready yet */
}
if (!ready) await new Promise((r) => setTimeout(r, 50));
}
if (!ready) throw new Error("Server failed to become ready within 2s");
await post("/api/lanes", { cwd: LANE_DIR, title: "CLI lane" });
});
after(() => {
try {
if (server) server.close();
fs.rmSync(LANE_DIR, { recursive: true, force: true });
fs.rmSync(PIPELINE_DIR, { recursive: true, force: true });
fs.rmSync(LANES_ROOT, { recursive: true, force: true });
fs.rmSync(SOURCE_REPO, { recursive: true, force: true });
fs.rmSync(REMOTE_REPO, { recursive: true, force: true });
} finally {
// Clean up TEST_DB and its WAL/SHM siblings (always runs, even if earlier cleanup fails)
fs.rmSync(TEST_DB, { force: true });
fs.rmSync(`${TEST_DB}-wal`, { force: true });
fs.rmSync(`${TEST_DB}-shm`, { force: true });
}
});
describe("ccam stage", () => {
it("reports a stage for the lane owning the current directory", async () => {
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /review/);
const list = await cli(["lanes"], LANE_DIR);
assert.match(list.stdout, /review/);
});
it("exits non-zero when no lane owns the cwd", async () => {
const r = await cli(["stage", "review"], os.tmpdir());
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
});
});
describe("ccam lanes add", () => {
it("creates a lane and it appears in lanes list", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-add-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created lane/i);
const list = await cli(["lanes"], addDir);
assert.equal(list.status, 0, list.stderr);
assert.match(list.stdout, /CLI added/);
} finally {
fs.rmSync(addDir, { recursive: true, force: true });
}
});
it("defaults to 'default' pipeline when --pipeline is omitted", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-no-pipeline-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created lane/i);
const details = await get(`/api/lanes`);
const lane = details.body.lanes.find((l) => l.title === "Default pipeline");
assert.ok(lane, "lane not found");
assert.equal(lane.pipeline, "default", "should use default pipeline when omitted");
} finally {
fs.rmSync(addDir, { recursive: true, force: true });
}
});
it("accepts --pipeline flag and creates lane with custom pipeline", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-custom-pipeline-"));
try {
const r = await cli(
[
"lanes",
"add",
"--cwd",
addDir,
"--title",
"Custom pipeline",
"--pipeline",
"test-pipeline",
],
addDir
);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created lane/i);
const details = await get(`/api/lanes`);
const lane = details.body.lanes.find((l) => l.title === "Custom pipeline");
assert.ok(lane, "lane not found");
assert.equal(lane.pipeline, "test-pipeline", "should use specified pipeline");
} finally {
fs.rmSync(addDir, { recursive: true, force: true });
}
});
it("provisions a managed worktree lane and reports it ready", async () => {
const r = await cli(
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
SOURCE_REPO
);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Worktree lane #\d+ ready: CLI worktree/);
const list = await get("/api/lanes");
const lane = list.body.lanes.find((l) => l.title === "CLI worktree");
assert.ok(lane, "managed lane not found");
assert.equal(lane.kind, "managed");
assert.equal(lane.status, "idle");
assert.ok(fs.existsSync(lane.cwd), "provisioned worktree directory is missing");
});
it("reports the failure notes, not success, when provisioning fails", async () => {
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-cli-unborn-repo-"));
try {
await git(["init", "-b", "main"], emptyRepo);
const r = await cli(
["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
emptyRepo
);
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /Worktree lane #\d+ failed:/);
assert.match(`${r.stdout}${r.stderr}`, /fatal:|ambiguous argument|unknown revision/i);
assert.doesNotMatch(r.stdout, /ready/i);
} finally {
fs.rmSync(emptyRepo, { recursive: true, force: true });
}
});
});
describe("ccam lanes destructive lifecycle", () => {
// Every test provisions its OWN lane. These tests used to share one mutable
// `managedLane` across describe blocks, which meant they only ever exercised
// whatever state the previous test happened to leave behind — the same
// shared-fixture reuse that let removeWorktree's destroy guard ship unpinned.
let laneSeq = 0;
async function ownLane(title) {
const slug = `cli-destructive-${(laneSeq += 1)}`;
const added = await cli(
["lanes", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
SOURCE_REPO
);
assert.equal(added.status, 0, added.stderr);
const list = await get("/api/lanes");
const lane = list.body.lanes.find((l) => l.slug === slug);
assert.ok(lane, `lane ${slug} not found`);
assert.equal(lane.status, "idle");
return lane;
}
it("prints reset preflight facts and changes nothing without --yes", async () => {
const lane = await ownLane("CLI reset dry run");
const dirtyFile = path.join(lane.cwd, "dirty.txt");
fs.writeFileSync(dirtyFile, "must survive\n");
const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /Preflight for reset lane #\d+:/);
assert.match(r.stdout, /dirty\s+0/);
assert.match(r.stdout, /untracked\s+1/);
assert.match(`${r.stdout}${r.stderr}`, /without --yes/);
assert.equal(fs.readFileSync(dirtyFile, "utf8"), "must survive\n");
});
it("resets a managed worktree with --yes and leaves it clean", async () => {
const lane = await ownLane("CLI reset applied");
fs.writeFileSync(path.join(lane.cwd, "dirty.txt"), "to be cleaned\n");
const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`Reset lane #${lane.id}`));
assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);
const preflight = await get(`/api/lanes/${lane.id}/preflight?action=reset`);
assert.equal(preflight.status, 200, JSON.stringify(preflight.body));
assert.equal(preflight.body.dirty, 0);
assert.equal(preflight.body.untracked, 0);
assert.equal(preflight.body.unpushed, 0);
});
it("prints expected-versus-current preflight facts on 409 ESTALE", async () => {
// Both CLI invocations read the same (clean) preflight before either takes
// the per-lane lock. The untracked file makes each read a non-trivial
// `untracked: 1`. Whichever process's POST acquires the lock first performs
// the reset (cleaning the file); the lock forces the second POST to wait,
// so by the time it re-checks preflight under the lock, the facts it
// captured before starting are stale.
const lane = await ownLane("CLI reset race");
fs.writeFileSync(path.join(lane.cwd, "race.txt"), "one more untracked file\n");
const [a, b] = await Promise.all([
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
]);
const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
assert.equal(winner.status, 0, winner.stderr);
assert.notEqual(loser.status, 0);
assert.match(`${loser.stdout}${loser.stderr}`, /lane state changed since preflight/);
assert.match(loser.stdout, /Expected preflight:/);
assert.match(loser.stdout, /Current preflight:/);
});
it("removes a managed lane's worktree and directory with --yes", async () => {
const lane = await ownLane("CLI remove managed");
assert.equal(fs.existsSync(lane.cwd), true);
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`Removed lane #${lane.id}`));
assert.equal(fs.existsSync(lane.cwd), false);
assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404);
});
it("refuses to RESET an adopted lane but FORGETS it on remove, leaving the directory intact", async () => {
const adoptedDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-adopted-remove-"));
const preserved = path.join(adoptedDir, "preserve.txt");
fs.writeFileSync(preserved, "do not delete\n");
try {
const created = await post("/api/lanes", {
cwd: adoptedDir,
title: "Adopted forget",
});
const id = created.body.lane.id;
const reset = await cli(["lanes", "reset", String(id), "--yes"], adoptedDir);
assert.notEqual(reset.status, 0);
assert.match(`${reset.stdout}${reset.stderr}`, /points at a directory you own/i);
// The server permits `remove` for an adopted lane — it drops the dashboard
// record only — so the CLI must not refuse it, or the capability is
// unreachable from the terminal.
const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir);
assert.equal(removed.status, 0, removed.stderr);
assert.match(removed.stdout, new RegExp(`Removed lane #${id}`));
assert.equal((await get(`/api/lanes/${id}`)).status, 404);
assert.equal(fs.existsSync(adoptedDir), true);
assert.equal(fs.readFileSync(preserved, "utf8"), "do not delete\n");
} finally {
fs.rmSync(adoptedDir, { recursive: true, force: true });
}
});
it("reports purge counts that exactly match its preflight", async () => {
const lane = await ownLane("CLI purge");
const preflight = await get(`/api/lanes/${lane.id}/preflight?action=purge`);
assert.equal(preflight.status, 200);
const { sessions, events, tokenRows } = preflight.body;
const r = await cli(["lanes", "purge", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(
r.stdout,
new RegExp(
`Purged lane #${lane.id}: ${sessions} sessions, ${events} events, ${tokenRows} token rows`
)
);
});
});
describe("ccam lanes — inferred stage detection", () => {
it("prints the inferred stage when detection leads the declared stage", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-detect-lead-"));
try {
const created = await post("/api/lanes", { cwd: dir, title: "Detect Lead" });
const id = created.body.lane.id;
// Default declared stage is 'idle' (no pipeline node), so a Bash
// test-run hook — which the default pipeline's `tests` node detects —
// leads it. Real path: POST /api/hooks/event, not writing the column
// directly.
await post("/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-lead",
cwd: dir,
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
const r = await cli(["lanes"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in ccam lanes output");
assert.match(line, /⇢ detected:tests/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("does not print an inferred stage when the declaration already leads", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-detect-behind-"));
try {
const created = await post("/api/lanes", { cwd: dir, title: "Detect Behind" });
const id = created.body.lane.id;
// Declare 'ship' (past 'tests' in the default pipeline) first, so the
// detection below is behind the declaration and recordDetection's
// declared-wins guard refuses to write it.
const staged = await cli(["stage", "ship", "--lane", String(id)], dir);
assert.equal(staged.status, 0, staged.stderr);
await post("/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-behind",
cwd: dir,
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
const r = await cli(["lanes"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in ccam lanes output");
assert.doesNotMatch(line, /detected:/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
+800
View File
@@ -0,0 +1,800 @@
/**
* @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("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");
describe("lanes lib", () => {
it("creates, lists, updates and deletes a lane", () => {
const l = lanes.createLane({ title: "Feature A", cwd: "/tmp/wt/a", branch: "feat/a" });
assert.equal(l.title, "Feature A");
assert.equal(l.stage, "idle");
assert.equal(lanes.getLane(l.id).cwd, "/tmp/wt/a");
assert.ok(lanes.listLanes().length >= 1);
assert.equal(lanes.updateLane(l.id, { ci_status: "green" }).ci_status, "green");
assert.equal(lanes.deleteLane(l.id), true);
assert.equal(lanes.getLane(l.id), null);
});
it("recovers only provisioning lanes interrupted by a restart", () => {
const provisioning = lanes.createLane({
cwd: "/tmp/wt/provisioning-recovery",
branch: "feat/provisioning-recovery",
kind: "managed",
});
lanes.updateLane(provisioning.id, { status: "provisioning" });
const idle = lanes.createLane({ cwd: "/tmp/wt/idle-recovery", branch: "feat/idle-recovery" });
const failed = lanes.createLane({
cwd: "/tmp/wt/failed-recovery",
branch: "feat/failed-recovery",
});
lanes.updateLane(failed.id, { status: "failed", notes: "existing failure" });
assert.equal(lanes.recoverInterruptedProvisioning(), 1);
const recovered = lanes.getLane(provisioning.id);
assert.equal(recovered.status, "failed");
assert.equal(recovered.notes, "Provisioning was interrupted by a server restart.");
assert.equal(recovered.kind, "managed");
assert.equal(recovered.cwd, "/tmp/wt/provisioning-recovery");
assert.equal(recovered.branch, "feat/provisioning-recovery");
assert.equal(lanes.getLane(idle.id).status, "idle");
assert.equal(lanes.getLane(failed.id).status, "failed");
assert.equal(lanes.getLane(failed.id).notes, "existing failure");
lanes.deleteLane(provisioning.id);
lanes.deleteLane(idle.id);
lanes.deleteLane(failed.id);
});
it("bumps stage_since only when the stage actually changes", async () => {
const l = lanes.createLane({ cwd: "/tmp/wt/b" });
const a = lanes.setStage(l.id, { stage: "plan" });
await new Promise((r) => setTimeout(r, 1100));
const b = lanes.setStage(l.id, { stage: "plan", note: "still planning" });
assert.equal(a.stage_since, b.stage_since);
const c = lanes.setStage(l.id, { stage: "implement" });
assert.notEqual(c.stage_since, b.stage_since);
lanes.deleteLane(l.id);
});
it("records evidence per stage so the map can tell done from amber", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/c" });
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
const after = lanes.setStage(l.id, { stage: "implement" });
assert.equal(after.stages.plan.evidence, "docs/plan.md");
assert.ok(after.stages.plan.enteredAt);
lanes.deleteLane(l.id);
});
it("resolves a lane from a session cwd by longest path-boundary prefix", () => {
const outer = lanes.createLane({ cwd: "/tmp/wt" });
const inner = lanes.createLane({ cwd: "/tmp/wt/inner" });
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/inner/src").id, inner.id);
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/other").id, outer.id);
assert.equal(lanes.resolveLaneByCwd("/tmp/wt-sibling"), null);
assert.equal(lanes.resolveLaneByCwd(null), null);
lanes.deleteLane(inner.id);
lanes.deleteLane(outer.id);
});
it("classifies liveness: silent watcher is dead, silent idle lane is not", () => {
const d = 300;
assert.equal(
lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 10 }, d),
"active"
);
assert.equal(
lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 999 }, d),
"dead"
);
assert.equal(
lanes.classifyLiveness({ status: "idle", stage: "watching-pr", ageSec: 999 }, d),
"dead"
);
assert.equal(
lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: 99999 }, d),
"idle"
);
assert.equal(
lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: null }, d),
"idle"
);
});
it("payload carries node states and progress", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/d" });
lanes.setStage(l.id, { stage: "review" });
const p = lanes.lanePayload(lanes.getLane(l.id), 5);
assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current");
assert.ok(p.progress > 0 && p.progress < 100);
assert.equal(p.liveness, "idle");
lanes.deleteLane(l.id);
});
});
const { withLaneLock } = require("../lib/lane-lock");
describe("lane kind, worktree fields and purge", () => {
it("defaults to adopted and stores worktree fields when given", () => {
const a = lanes.createLane({ cwd: "/tmp/wt-kind-a" });
assert.equal(a.kind, "adopted");
const m = lanes.createLane({
cwd: "/tmp/wt-kind-b",
kind: "managed",
source_repo: "/tmp/src",
base_branch: "main",
slug: "b",
});
assert.equal(m.kind, "managed");
assert.equal(m.source_repo, "/tmp/src");
assert.equal(m.base_branch, "main");
assert.equal(m.slug, "b");
lanes.deleteLane(a.id);
lanes.deleteLane(m.id);
});
it("rejects an unknown kind", () => {
assert.throws(
() => lanes.createLane({ cwd: "/tmp/wt-kind-c", kind: "gremlin" }),
(e) => e.code === "EBADKIND"
);
});
it("purges a lane's sessions, their events and orphaned token rows, sparing the live one", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-purge" });
const { db } = require("../db");
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"purge-1",
"/tmp/wt-purge/sub"
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run(
"purge-live",
"/tmp/wt-purge"
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'PostToolUse')").run(
"purge-1"
);
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 5)").run(
"purge-1"
);
lanes.updateLane(l.id, { session_id: "purge-live" });
const counts = lanes.purgeLaneSessions(l.id);
assert.equal(counts.sessions, 1);
assert.equal(counts.events, 1);
assert.equal(counts.tokenRows, 1);
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='purge-live'").get().c, 1);
assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='purge-1'").get().c, 0);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM token_usage WHERE session_id='purge-1'").get().c,
0
);
lanes.deleteLane(l.id);
});
it("never counts or deletes a sibling directory whose name differs only at an underscore", () => {
// Every managed lane directory is named `<repo>__<slug>` — two literal
// underscores, each of which LIKE treats as "any single character". Without
// ESCAPE, a lane at /root/myrepo__feat-foo purged /root/myrepoXXfeat-foo's
// sessions too, and the preflight count reported the victims, so the
// confirmation was consistently wrong rather than detectably wrong.
const { db } = require("../db");
const laneCwd = "/root/myrepo__feat-foo";
const siblingCwd = "/root/myrepoXXfeat-foo";
const l = lanes.createLane({ cwd: laneCwd, kind: "managed" });
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"like-mine",
`${laneCwd}/sub`
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run(
"like-victim",
`${siblingCwd}/sub`
);
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run(
"like-victim-active",
`${siblingCwd}/other`
);
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'Stop')").run("like-victim");
// The counter sees only this lane's own session.
const candidates = lanes.purgeCandidateSessions(lanes.getLane(l.id));
assert.deepEqual(
candidates.map((s) => s.id),
["like-mine"]
);
// ...and does not attribute the sibling's live session to this lane either.
assert.equal(lanes.hasActiveLaneSession(lanes.getLane(l.id)), false);
// The deleter agrees with the counter, exactly.
const counts = lanes.purgeLaneSessions(l.id);
assert.equal(counts.sessions, 1);
assert.equal(counts.events, 0);
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='like-victim'").get().c, 1);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='like-victim-active'").get().c,
1
);
assert.equal(
db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='like-victim'").get().c,
1
);
lanes.deleteLane(l.id);
});
it("a PATCH-style updateLane cannot change kind, source_repo, slug or base_branch", () => {
// kind is check 1 of the destroy guard: a client that could flip it to
// "managed" could point the guard at a directory the user owns.
const l = lanes.createLane({
cwd: "/tmp/wt-patch-guard",
kind: "adopted",
source_repo: "/tmp/original-src",
base_branch: "main",
slug: "original",
});
const after = lanes.updateLane(l.id, {
kind: "managed",
source_repo: "/tmp/attacker-src",
base_branch: "attacker",
slug: "attacker",
title: "this one IS patchable",
});
assert.equal(after.kind, "adopted");
assert.equal(after.source_repo, "/tmp/original-src");
assert.equal(after.base_branch, "main");
assert.equal(after.slug, "original");
assert.equal(after.title, "this one IS patchable");
// Provisioning has its own internal writer for those facts.
const provisioned = lanes.setProvisioningFacts(l.id, { base_branch: "resolved-base" });
assert.equal(provisioned.base_branch, "resolved-base");
assert.equal(provisioned.kind, "adopted");
lanes.deleteLane(l.id);
});
it("serialises work per lane and releases the lock when the body throws", async () => {
const order = [];
const slow = withLaneLock(7, async () => {
order.push("a-start");
await new Promise((r) => setTimeout(r, 50));
order.push("a-end");
});
const fast = withLaneLock(7, async () => {
order.push("b");
});
await Promise.all([slow, fast]);
assert.deepEqual(order, ["a-start", "a-end", "b"]);
await assert.rejects(() =>
withLaneLock(7, async () => {
throw new Error("boom");
})
);
await withLaneLock(7, async () => order.push("c"));
assert.equal(order[order.length - 1], "c");
});
it("updateLane rejects an unknown kind with EBADKIND and leaves stored value unchanged", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-kind-update", kind: "adopted" });
assert.equal(l.kind, "adopted");
assert.throws(
() => lanes.updateLane(l.id, { kind: "MANAGED" }),
(e) => e.code === "EBADKIND"
);
const after = lanes.getLane(l.id);
assert.equal(after.kind, "adopted");
lanes.deleteLane(l.id);
});
it("different lane ids have separate locks and run in parallel", async () => {
const order = [];
const lane1Slow = withLaneLock(10, async () => {
order.push("lane1-start");
await new Promise((r) => setTimeout(r, 50));
order.push("lane1-end");
});
const lane2 = withLaneLock(11, async () => {
order.push("lane2");
});
await Promise.all([lane1Slow, lane2]);
assert.ok(order.includes("lane2"), "lane 2 should run without waiting for lane 1");
assert.notDeepEqual(order, ["lane1-start", "lane1-end", "lane2"]);
});
it("migration: old schema database gains all four columns idempotently", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
`test-lanes-migration-${Date.now()}-${process.pid}.db`
);
try {
// Create an old-schema lanes table with only id, cwd, title columns
const OldDatabase = require("better-sqlite3");
const oldDb = new OldDatabase(tmpPath);
oldDb.exec(`
CREATE TABLE lanes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL UNIQUE,
branch TEXT,
pipeline TEXT NOT NULL DEFAULT 'default',
session_id TEXT,
run_id TEXT,
stage TEXT NOT NULL DEFAULT 'idle',
stage_since TEXT,
status TEXT NOT NULL DEFAULT 'idle',
gate_decision TEXT,
ci_status TEXT,
needs_action TEXT,
links TEXT NOT NULL DEFAULT '{}',
stages TEXT NOT NULL DEFAULT '{}',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
oldDb
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, stage_since) VALUES (?, ?, ?, ?, ?)"
)
.run("old-lane", "/tmp/old-lane-path", "main", "default", "2026-07-27T00:00:00Z");
oldDb.close();
// Load db.js against it, which should add the four columns
process.env.DASHBOARD_DB_PATH = tmpPath;
delete require.cache[require.resolve("../db")];
const { db: newDb } = require("../db");
// Verify all four columns exist
const checkCols = [
"SELECT kind FROM lanes LIMIT 1",
"SELECT source_repo FROM lanes LIMIT 1",
"SELECT base_branch FROM lanes LIMIT 1",
"SELECT slug FROM lanes LIMIT 1",
];
checkCols.forEach((sql) => {
assert.doesNotThrow(() => newDb.prepare(sql).get());
});
// Verify existing row has kind='adopted' and others NULL
const row = newDb.prepare("SELECT * FROM lanes WHERE cwd = ?").get("/tmp/old-lane-path");
assert.equal(row.kind, "adopted");
assert.equal(row.source_repo, null);
assert.equal(row.base_branch, null);
assert.equal(row.slug, null);
} finally {
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
delete require.cache[require.resolve("../db")];
try {
require("fs").rmSync(tmpPath, { force: true });
} catch {
/* cleanup best effort */
}
}
});
});
describe("stage detection", () => {
it("writes a forward detection", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-forward" });
const result = lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" });
assert.deepEqual(result, { written: true });
const after = lanes.getLane(l.id);
assert.equal(after.detected_stage, "implement");
assert.equal(after.detected_signal, "`Edit`");
assert.ok(after.detected_at);
lanes.deleteLane(l.id);
});
it("drops a detection that does not advance past the current detected stage", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-backward" });
lanes.recordDetection(l.id, { nodeId: "tests" });
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(result, { written: false, reason: "behind-detected" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
lanes.deleteLane(l.id);
});
it("drops a detection at or behind the declared stage", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-declared" });
lanes.setStage(l.id, { stage: "review" });
const atDeclared = lanes.recordDetection(l.id, { nodeId: "review" });
assert.deepEqual(atDeclared, { written: false, reason: "behind-declared" });
const behindDeclared = lanes.recordDetection(l.id, { nodeId: "implement" });
assert.deepEqual(behindDeclared, { written: false, reason: "behind-declared" });
assert.equal(lanes.getLane(l.id).detected_stage, null);
lanes.deleteLane(l.id);
});
it("reports unknown-node for a node id the pipeline does not have", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-unknown" });
const result = lanes.recordDetection(l.id, { nodeId: "not-a-real-node" });
assert.deepEqual(result, { written: false, reason: "unknown-node" });
assert.equal(lanes.getLane(l.id).detected_stage, null);
lanes.deleteLane(l.id);
});
it("PREMISE GUARD: a lane with only detections and no declarations has no done node", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-no-done" });
lanes.recordDetection(l.id, { nodeId: "review", signal: "`git diff`" });
const payload = lanes.lanePayload(lanes.getLane(l.id));
assert.ok(
!payload.pipeline_nodes.some((n) => n.state === "done"),
"inference must never render a node done — only a declared stage carrying evidence may"
);
// lane.stage is still the default "idle", which is not itself a pipeline
// node/alias, so nodeStates() (unaware of detection) reports "pending" for
// every node here — detection only ever adds the `detected` flag alongside
// whatever nodeStates() already computed from the declared stage.
const review = payload.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "pending");
assert.equal(review.detected, true);
const implement = payload.pipeline_nodes.find((n) => n.id === "implement");
assert.equal(implement.detected, true);
const done = payload.pipeline_nodes.find((n) => n.id === "done");
assert.equal(done.detected, false);
lanes.deleteLane(l.id);
});
it("PREMISE GUARD: a node declared WITH evidence stays done and unflagged under a detection ahead of it", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-done-guard" });
// implement declared with evidence, then the lane declares tests; a review
// detection lands ahead of both. `implement` is the adversarial node: it is
// `done` (declared + evidence) and it sits before the detected index.
lanes.setStage(l.id, { stage: "implement", evidence: "server/lib/x.js" });
lanes.setStage(l.id, { stage: "tests" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "review", signal: "`git diff`" }), {
written: true,
});
const nodes = lanes.lanePayload(lanes.getLane(l.id)).pipeline_nodes;
const implement = nodes.find((n) => n.id === "implement");
assert.equal(implement.state, "done");
assert.equal(implement.detected, false);
lanes.deleteLane(l.id);
});
it("a stage declared by ALIAS keeps its current ring instead of reading as an inference", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-alias-current" });
// "coding" is an alias of the `implement` node, so `stages` gets no entry
// under "implement" — the declared current node must still not be flagged.
lanes.setStage(l.id, { stage: "coding", evidence: "server/lib/x.js" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }), {
written: true,
});
const nodes = lanes.lanePayload(lanes.getLane(l.id)).pipeline_nodes;
const implement = nodes.find((n) => n.id === "implement");
assert.equal(implement.state, "current");
assert.equal(implement.detected, false);
lanes.deleteLane(l.id);
});
it("never writes the declared stage: stage and stage_since are byte-identical after a detection", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-no-stage-write" });
lanes.setStage(l.id, { stage: "plan" });
const before = lanes.getLane(l.id);
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }), {
written: true,
});
const after = lanes.getLane(l.id);
assert.equal(after.stage, before.stage);
assert.equal(after.stage_since, before.stage_since);
lanes.deleteLane(l.id);
});
it("clearLane nulls the detection columns and lets a fresh detection land immediately", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-clear" });
lanes.recordDetection(l.id, { nodeId: "ship", signal: "`git push`" });
assert.equal(lanes.getLane(l.id).detected_stage, "ship");
const cleared = lanes.clearLane(l.id);
assert.equal(cleared.detected_stage, null);
assert.equal(cleared.detected_signal, null);
assert.equal(cleared.detected_at, null);
assert.ok(
!lanes.lanePayload(cleared).pipeline_nodes.some((n) => n.detected),
"a cleared lane must claim no inferred progress"
);
// Without the reset, `ship` would forever outrank every later detection.
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
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" });
// Stand up the detection BEFORE declaring, otherwise declared-wins refuses
// it and there is no standing detection left to age.
lanes.recordDetection(l.id, { nodeId: "tests" });
lanes.setStage(l.id, { stage: "review" });
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 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;
}
}
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* @file Tests for the Prometheus metrics endpoint (GET /api/metrics). Verifies
* the exposition format (HELP/TYPE headers, content-type, no NaN samples), that
* the enumerated status gauges are always present (so a series never drops out
* at zero), and that the numbers track real data seeded through the hook API.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `dashboard-metrics-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function request(method, urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
}
);
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
/** Parse a `name{labels} value` sample line's value, or null if not present. */
function sampleValue(text, name, labels) {
const needle = labels ? `${name}{${labels}}` : name;
for (const line of text.split("\n")) {
if (line.startsWith("#")) continue;
if (labels ? line.startsWith(needle + " ") : line.startsWith(needle + " ")) {
return Number(line.slice(needle.length).trim());
}
}
return null;
}
describe("GET /api/metrics", () => {
it("serves a well-formed Prometheus exposition", async () => {
const res = await request("GET", "/api/metrics");
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /text\/plain/);
assert.match(res.headers["content-type"], /version=0\.0\.4/);
// Core families present with HELP + TYPE headers.
for (const name of [
"ccam_up",
"ccam_build_info",
"ccam_process_uptime_seconds",
"ccam_process_resident_memory_bytes",
"ccam_sessions",
"ccam_agents",
"ccam_events_total",
"ccam_websocket_clients",
"ccam_remote_sources",
"ccam_tokens_total",
]) {
assert.ok(res.body.includes(`# HELP ${name} `), `HELP for ${name}`);
assert.ok(res.body.includes(`# TYPE ${name} `), `TYPE for ${name}`);
}
assert.equal(sampleValue(res.body, "ccam_up"), 1);
// No sample line may carry a NaN/undefined value.
for (const line of res.body.split("\n")) {
if (!line || line.startsWith("#")) continue;
const value = line.slice(line.lastIndexOf(" ") + 1);
assert.ok(!Number.isNaN(Number(value)), `numeric sample value on line: ${line}`);
}
});
it("emits every enumerated status series even at zero", async () => {
const res = await request("GET", "/api/metrics");
for (const status of ["active", "completed", "error", "abandoned"]) {
assert.notEqual(
sampleValue(res.body, "ccam_sessions", `status="${status}"`),
null,
`ccam_sessions status=${status} present`
);
}
for (const status of ["working", "waiting", "completed", "error"]) {
assert.notEqual(
sampleValue(res.body, "ccam_agents", `status="${status}"`),
null,
`ccam_agents status=${status} present`
);
}
});
it("reflects real data seeded through the hook API", async () => {
const before = sampleValue(await scrapeText(), "ccam_sessions", 'status="active"') || 0;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "metrics-sess-1", cwd: "/tmp/metrics" },
});
const after = sampleValue(await scrapeText(), "ccam_sessions", 'status="active"');
assert.equal(after, before + 1, "a new active session bumps the gauge by 1");
});
async function scrapeText() {
return (await request("GET", "/api/metrics")).body;
}
});
+53
View File
@@ -0,0 +1,53 @@
/**
* @file Unit tests for cross-platform monitoring binary URL/path resolution.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
prometheusUrl,
grafanaUrl,
prometheusArchiveExt,
grafanaArchiveExt,
prometheusPlatform,
prometheusArchiveName,
grafanaArchiveName,
toGrafanaPath,
} = require("../../monitoring/scripts/paths");
describe("monitoring paths", () => {
it("builds official download URLs for the current platform", () => {
const plat = prometheusPlatform();
const promExt = prometheusArchiveExt();
const grafExt = grafanaArchiveExt();
assert.match(
prometheusUrl(),
new RegExp(`${prometheusArchiveName()}\\.${promExt.replace(".", "\\.")}$`)
);
assert.match(
grafanaUrl(),
new RegExp(`grafana-[0-9.]+\\.${plat}\\.${grafExt.replace(".", "\\.")}$`)
);
if (process.platform === "win32") {
assert.equal(promExt, "zip");
assert.equal(grafExt, "zip");
} else {
assert.equal(promExt, "tar.gz");
assert.equal(grafExt, "tar.gz");
}
});
it("normalizes Windows paths for Grafana YAML", () => {
assert.equal(
toGrafanaPath("C:\\ccam\\monitoring\\grafana\\dashboards"),
"C:/ccam/monitoring/grafana/dashboards"
);
});
it("uses consistent archive naming", () => {
assert.match(prometheusArchiveName(), /^prometheus-[0-9.]+\./);
assert.match(grafanaArchiveName(), /^grafana-[0-9.]+\./);
});
});
@@ -0,0 +1,170 @@
/**
* @file plugins-marketplace.test.js
* @description Structural validation for the bundled Claude Code plugin
* marketplace (.claude-plugin/marketplace.json + plugins/*). Guards that
* every marketplace entry resolves to a real plugin dir with a valid
* plugin.json, that names line up, and that every agent / skill / command
* file carries the frontmatter Claude Code requires. Pure file reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { parseFrontmatter } = require("../lib/cc-discovery");
const REPO_ROOT = path.join(__dirname, "..", "..");
const PLUGINS_DIR = path.join(REPO_ROOT, "plugins");
const MARKETPLACE = path.join(REPO_ROOT, ".claude-plugin", "marketplace.json");
function readJson(p) {
return JSON.parse(fs.readFileSync(p, "utf8"));
}
function listDirs(p) {
return fs
.readdirSync(p, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
}
function listMd(dir) {
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isFile() && e.name.endsWith(".md"))
.map((e) => e.name);
} catch {
return [];
}
}
describe("plugin marketplace", () => {
const marketplace = readJson(MARKETPLACE);
const pluginDirs = listDirs(PLUGINS_DIR).sort();
const entryNames = marketplace.plugins.map((p) => p.name).sort();
it("marketplace.json has the required top-level shape", () => {
assert.equal(typeof marketplace.name, "string");
assert.ok(marketplace.name.length > 0);
assert.equal(typeof marketplace.description, "string");
assert.ok(marketplace.owner && typeof marketplace.owner.name === "string");
assert.ok(Array.isArray(marketplace.plugins));
});
it("ships at least 10 plugins", () => {
assert.ok(
marketplace.plugins.length >= 10,
`expected >=10 marketplace entries, got ${marketplace.plugins.length}`
);
assert.ok(pluginDirs.length >= 10, `expected >=10 plugin dirs, got ${pluginDirs.length}`);
});
it("marketplace entries and plugin dirs are a bijection", () => {
assert.deepEqual(
entryNames,
pluginDirs,
`marketplace entries (${entryNames}) must match plugin dirs (${pluginDirs})`
);
});
for (const entry of marketplace.plugins) {
describe(`entry: ${entry.name}`, () => {
it("has name, path, description, tags", () => {
assert.equal(typeof entry.name, "string");
assert.equal(entry.path, `plugins/${entry.name}`);
assert.equal(typeof entry.description, "string");
assert.ok(entry.description.length > 20);
assert.ok(Array.isArray(entry.tags) && entry.tags.length > 0);
});
it("path exists on disk", () => {
assert.ok(fs.existsSync(path.join(REPO_ROOT, entry.path)));
});
});
}
for (const dir of pluginDirs) {
describe(`plugin: ${dir}`, () => {
const root = path.join(PLUGINS_DIR, dir);
const manifestPath = path.join(root, ".claude-plugin", "plugin.json");
it("has a valid plugin.json whose name matches the dir", () => {
assert.ok(fs.existsSync(manifestPath), `${dir} is missing .claude-plugin/plugin.json`);
const m = readJson(manifestPath);
assert.equal(m.name, dir, `${dir}/plugin.json name must equal the dir name`);
assert.equal(typeof m.description, "string");
assert.ok(m.description.length > 20);
assert.equal(typeof m.version, "string");
assert.ok(m.author && typeof m.author.name === "string");
assert.equal(typeof m.license, "string");
assert.ok(Array.isArray(m.keywords) && m.keywords.length > 0);
});
it("agents carry valid frontmatter (name === filename, description)", () => {
const agentsDir = path.join(root, "agents");
for (const f of listMd(agentsDir)) {
const { frontmatter } = parseFrontmatter(
fs.readFileSync(path.join(agentsDir, f), "utf8")
);
assert.ok(frontmatter, `${dir}/agents/${f} has no frontmatter`);
assert.equal(
frontmatter.name,
f.replace(/\.md$/, ""),
`${dir}/agents/${f} frontmatter name must equal the filename`
);
assert.ok(frontmatter.description, `${dir}/agents/${f} missing description`);
}
});
it("skills carry a description in SKILL.md frontmatter", () => {
const skillsDir = path.join(root, "skills");
let skillDirs = [];
try {
skillDirs = listDirs(skillsDir);
} catch {
skillDirs = [];
}
for (const s of skillDirs) {
const file = path.join(skillsDir, s, "SKILL.md");
assert.ok(fs.existsSync(file), `${dir}/skills/${s} is missing SKILL.md`);
const { frontmatter } = parseFrontmatter(fs.readFileSync(file, "utf8"));
assert.ok(frontmatter, `${dir}/skills/${s}/SKILL.md has no frontmatter`);
assert.ok(frontmatter.description, `${dir}/skills/${s}/SKILL.md missing description`);
}
});
it("commands carry a description in frontmatter", () => {
const cmdDir = path.join(root, "commands");
for (const f of listMd(cmdDir)) {
const { frontmatter } = parseFrontmatter(fs.readFileSync(path.join(cmdDir, f), "utf8"));
assert.ok(frontmatter, `${dir}/commands/${f} has no frontmatter`);
assert.ok(frontmatter.description, `${dir}/commands/${f} missing description`);
}
});
it("hooks.json (if present) is valid JSON with a hooks object", () => {
const hooksFile = path.join(root, "hooks", "hooks.json");
if (!fs.existsSync(hooksFile)) return;
const h = readJson(hooksFile);
assert.ok(
h.hooks && typeof h.hooks === "object",
`${dir}/hooks/hooks.json needs a hooks object`
);
});
it("contributes at least one skill or agent", () => {
const skills = (() => {
try {
return listDirs(path.join(root, "skills")).length;
} catch {
return 0;
}
})();
const agents = listMd(path.join(root, "agents")).length;
assert.ok(skills + agents > 0, `${dir} contributes no skills or agents`);
});
});
}
});
+326
View File
@@ -0,0 +1,326 @@
/**
* @file Unit tests for the enhanced pricing calculator and the shared token-usage
* normalizer: 5m/1h cache-write split, server-tool surcharges, and the per-bucket
* pricing modifiers (fast mode, US data residency, Batch API).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { calculateCost } = require("../routes/pricing");
const {
normalizeSpeed,
normalizeGeo,
normalizeTier,
extractUsageFields,
} = require("../lib/token-usage");
const M = 1_000_000;
// One Opus-4.8-shaped rule with fast pricing, used across the cost tests.
const RULES = [
{
model_pattern: "claude-opus-4-8%",
display_name: "Claude Opus 4.8",
input_per_mtok: 5,
output_per_mtok: 25,
cache_read_per_mtok: 0.5,
cache_write_per_mtok: 6.25,
cache_write_1h_per_mtok: 10,
fast_input_per_mtok: 10,
fast_output_per_mtok: 50,
},
];
function bucket(extra) {
return {
model: "claude-opus-4-8",
speed: "standard",
inference_geo: "global",
service_tier: "standard",
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
cache_write_1h_tokens: 0,
web_search_requests: 0,
web_fetch_requests: 0,
code_execution_requests: 0,
...extra,
};
}
describe("token-usage normalizer", () => {
it("normalizes pricing dimensions, collapsing unknowns to standard/global", () => {
assert.equal(normalizeSpeed({ speed: "fast" }), "fast");
assert.equal(normalizeSpeed({ speed: "standard" }), "standard");
assert.equal(normalizeSpeed({}), "standard");
assert.equal(normalizeGeo({ inference_geo: "us" }), "us");
assert.equal(normalizeGeo({ inference_geo: "not_available" }), "global");
assert.equal(normalizeGeo({}), "global");
assert.equal(normalizeTier({ service_tier: "batch" }), "batch");
assert.equal(normalizeTier({ service_tier: "priority" }), "standard");
});
it("splits 5m vs 1h cache writes from cache_creation breakdown", () => {
const f = extractUsageFields({
input_tokens: 100,
output_tokens: 200,
cache_read_input_tokens: 50,
cache_creation_input_tokens: 80,
cache_creation: { ephemeral_5m_input_tokens: 30, ephemeral_1h_input_tokens: 50 },
server_tool_use: {
web_search_requests: 2,
web_fetch_requests: 1,
code_execution_requests: 3,
},
});
assert.equal(f.input, 100);
assert.equal(f.output, 200);
assert.equal(f.cacheRead, 50);
assert.equal(f.cacheWrite, 80);
assert.equal(f.cacheWrite1h, 50);
assert.equal(f.webSearch, 2);
assert.equal(f.webFetch, 1);
assert.equal(f.codeExec, 3);
});
it("treats the old shape (no breakdown / no tool use) as all-5m, zero tools", () => {
const f = extractUsageFields({
input_tokens: 10,
output_tokens: 20,
cache_read_input_tokens: 5,
cache_creation_input_tokens: 40,
});
assert.equal(f.cacheWrite, 40);
assert.equal(f.cacheWrite1h, 0); // backward compatible: priced at the 5m rate
assert.equal(f.webSearch, 0);
assert.equal(f.codeExec, 0);
});
});
describe("calculateCost — token rates", () => {
it("prices standard input/output/read/5m/1h correctly", () => {
const r = calculateCost(
[
bucket({
input_tokens: M,
output_tokens: M,
cache_read_tokens: M,
cache_write_tokens: M,
cache_write_1h_tokens: 0,
}),
],
RULES
);
// 5 + 25 + 0.5 + 6.25(5m) = 36.75
assert.equal(r.total_cost, 36.75);
});
it("splits a mixed cache_write into 5m and 1h portions", () => {
const r = calculateCost(
[bucket({ cache_write_tokens: M, cache_write_1h_tokens: 0.4 * M })],
RULES
);
// 0.6M @ 6.25 + 0.4M @ 10 = 3.75 + 4 = 7.75
assert.equal(r.total_cost, 7.75);
});
it("falls back to zero cost when no rule matches and surfaces the unpriced model", () => {
const r = calculateCost([bucket({ model: "gpt-4o", input_tokens: M })], RULES);
assert.equal(r.total_cost, 0);
assert.equal(r.breakdown[0].matched_rule, null);
assert.equal(r.unpriced_models.length, 1);
assert.equal(r.unpriced_models[0].model, "gpt-4o");
assert.equal(r.unpriced_models[0].input_tokens, M);
});
});
describe("calculateCost — modifiers", () => {
it("applies fast-mode premium (input/output) and scales cache from fast input", () => {
const r = calculateCost(
[
bucket({
speed: "fast",
input_tokens: M,
output_tokens: M,
cache_write_tokens: M,
cache_write_1h_tokens: M,
}),
],
RULES
);
// fast input 10, output 50, 1h-write = 10 * (10/5) = 20 => 80
assert.equal(r.total_cost, 80);
});
it("applies the US data-residency 1.1x multiplier", () => {
const r = calculateCost(
[
bucket({
inference_geo: "us",
input_tokens: M,
output_tokens: M,
cache_write_tokens: M,
cache_write_1h_tokens: M,
}),
],
RULES
);
// (5 + 25 + 10) * 1.1 = 44
assert.equal(r.total_cost, 44);
});
it("applies the Batch API 50% discount", () => {
const r = calculateCost(
[
bucket({
service_tier: "batch",
input_tokens: M,
output_tokens: M,
cache_write_tokens: M,
cache_write_1h_tokens: M,
}),
],
RULES
);
// (5 + 25 + 10) * 0.5 = 20
assert.equal(r.total_cost, 20);
});
});
describe("calculateCost — server-tool surcharges", () => {
it("charges web search at $10 / 1,000 searches", () => {
const r = calculateCost([bucket({ web_search_requests: 2500 })], RULES);
assert.equal(r.total_cost, 25);
assert.equal(r.feature_costs.web_search_cost, 25);
});
it("charges nothing for web fetch", () => {
const r = calculateCost([bucket({ web_fetch_requests: 9999 })], RULES);
assert.equal(r.total_cost, 0);
assert.equal(r.feature_costs.web_fetch_cost, 0);
});
it("treats code execution as free under the monthly allowance", () => {
const r = calculateCost([bucket({ code_execution_requests: 100 })], RULES);
assert.equal(r.feature_costs.code_execution_cost, 0); // well under 1550 free hours
assert.ok(r.feature_costs.code_execution_hours_estimated > 0);
});
it("treats code execution as free when used alongside web search", () => {
const r = calculateCost(
[bucket({ code_execution_requests: 1000000, web_search_requests: 1 })],
RULES
);
// free-with-search => 0 estimated hours despite huge request count (search surcharge only)
assert.equal(r.feature_costs.code_execution_hours_estimated, 0);
assert.equal(r.feature_costs.code_execution_cost, 0);
});
it("charges code execution beyond the free allowance", () => {
// 12 requests/hour at the 5-min minimum; exceed 1550 free hours to force a charge.
const requests = (1550 + 100) * 12; // 100 billable hours over the allowance
const r = calculateCost([bucket({ code_execution_requests: requests })], RULES);
assert.equal(r.feature_costs.code_execution_cost, 5); // 100 hrs * $0.05
});
});
describe("calculateCost — model_pattern matching (dated ids, no cross-match)", () => {
// Sonnet-5 alongside Sonnet-4.6 + Opus, mirroring the seeded DEFAULT_PRICING.
const FAMILY = [
{ model_pattern: "claude-opus-4-8%", input_per_mtok: 5, output_per_mtok: 25 },
{ model_pattern: "claude-sonnet-5%", input_per_mtok: 3, output_per_mtok: 15 },
{ model_pattern: "claude-sonnet-4-6%", input_per_mtok: 3, output_per_mtok: 15 },
];
const priceOf = (model) => {
const r = calculateCost([bucket({ model, output_tokens: M })], FAMILY);
return { cost: r.total_cost, unpriced: r.unpriced_models.map((u) => u.model) };
};
it("prices bare claude-sonnet-5 (not $0, not unpriced)", () => {
const { cost, unpriced } = priceOf("claude-sonnet-5");
assert.equal(cost, 15); // 1M output * $15
assert.deepEqual(unpriced, []);
});
it("prices a dated claude-sonnet-5-YYYYMMDD via the % suffix", () => {
const { cost, unpriced } = priceOf("claude-sonnet-5-20260615");
assert.equal(cost, 15);
assert.deepEqual(unpriced, []);
});
it("does not cross-match sonnet-5 ↔ sonnet-4.x (both stay priced by their own rule)", () => {
// If claude-sonnet-5 wrongly matched the 4.6 rule (or vice versa) via a
// greedy/short pattern, one of these would resolve to the wrong row. Both
// are $3/$15 here, so the real guard is that neither is left UNPRICED and
// the sonnet-4.5 (absent) case IS surfaced as unpriced.
assert.deepEqual(priceOf("claude-sonnet-4-6").unpriced, []);
assert.deepEqual(priceOf("claude-sonnet-5").unpriced, []);
// A model with no rule (sonnet-4-5 not in FAMILY) must be reported unpriced,
// proving sonnet-5%/sonnet-4-6% don't greedily swallow it.
assert.deepEqual(priceOf("claude-sonnet-4-5").unpriced, ["claude-sonnet-4-5"]);
});
});
describe("calculateCost — date-effective (intro) pricing", () => {
// Sonnet-5-shaped rule: intro $2/$10 through 2026-08-31, standard $3/$15 after.
const INTRO = [
{
model_pattern: "claude-sonnet-5%",
input_per_mtok: 3,
output_per_mtok: 15,
cache_read_per_mtok: 0.3,
cache_write_per_mtok: 3.75,
cache_write_1h_per_mtok: 6,
intro_input_per_mtok: 2,
intro_output_per_mtok: 10,
intro_cache_read_per_mtok: 0.2,
intro_cache_write_per_mtok: 2.5,
intro_cache_write_1h_per_mtok: 4,
intro_until: "2026-08-31",
},
];
// 1M output → intro $10, standard $15.
const cost = (asOf, rowDate) =>
calculateCost(
[{ ...bucket({ model: "claude-sonnet-5", output_tokens: M }), date: rowDate }],
INTRO,
asOf
).total_cost;
it("uses intro rate before the cutoff (asOf)", () => {
assert.equal(cost("2026-07-01", undefined), 10);
});
it("uses intro rate on the cutoff day (inclusive)", () => {
assert.equal(cost("2026-08-31", undefined), 10);
});
it("uses standard rate after the cutoff", () => {
assert.equal(cost("2026-09-01", undefined), 15);
assert.equal(cost("2026-10-15", undefined), 15);
});
it("prefers the row's own date over asOf (per-day pricing)", () => {
// asOf is post-cutoff, but the row is dated pre-cutoff → intro applies.
assert.equal(cost("2026-12-01", "2026-08-01"), 10);
// and vice versa
assert.equal(cost("2026-07-01", "2026-09-15"), 15);
});
it("a rule with no intro_until always uses standard rate", () => {
const NO_INTRO = [
{ model_pattern: "claude-sonnet-5%", input_per_mtok: 3, output_per_mtok: 15 },
];
assert.equal(
calculateCost(
[bucket({ model: "claude-sonnet-5", output_tokens: M })],
NO_INTRO,
"2026-07-01"
).total_cost,
15
);
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* @file Tests for editing time-limited introductory pricing via PUT /api/pricing.
*
* The Settings page lets users edit a model's introductory (promo) rates, not
* just its standard rates. This suite verifies the route contract that backs
* that UI, and that the mechanism is generic (works for any model pattern, not
* just the seeded Sonnet 5 promo):
*
* 1. PUT with intro fields + a valid intro_until persists the intro rates.
* 2. PUT that omits every intro field preserves an existing promo (backward
* compatible with older clients that only send standard rates).
* 3. PUT with an empty intro_until clears the promo AND zeroes the intro
* rates so a stale value can't resurface later.
* 4. A malformed intro_until is rejected with 400 and writes nothing.
* 5. Editing standard rates never disturbs the intro block.
*
* It also covers the route's numeric-rate validation: any present rate field
* must be a non-negative finite number, else the PUT is rejected with 400 and
* nothing is written (NaN/negative rates would corrupt all cost math).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `dashboard-intro-edit-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
let server;
let BASE;
function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const PATTERN = "test-promo-model%";
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
describe("PUT /api/pricing — introductory rate editing", () => {
it("persists intro rates when a valid intro_until is supplied", async () => {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 3,
output_per_mtok: 15,
intro_until: "2026-08-31",
intro_input_per_mtok: 2,
intro_output_per_mtok: 10,
intro_cache_read_per_mtok: 0.2,
intro_cache_write_per_mtok: 2.5,
intro_cache_write_1h_per_mtok: 4,
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
assert.equal(row.intro_until, "2026-08-31");
assert.equal(row.intro_input_per_mtok, 2);
assert.equal(row.intro_output_per_mtok, 10);
assert.equal(row.intro_cache_read_per_mtok, 0.2);
assert.equal(row.intro_cache_write_per_mtok, 2.5);
assert.equal(row.intro_cache_write_1h_per_mtok, 4);
// Standard rates persisted too.
assert.equal(row.input_per_mtok, 3);
assert.equal(row.output_per_mtok, 15);
});
it("preserves an existing promo when the PUT omits all intro fields", async () => {
// Older client shape: standard rates only, no intro_* keys at all.
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model (renamed)",
input_per_mtok: 4,
output_per_mtok: 16,
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
// Standard rates updated…
assert.equal(row.input_per_mtok, 4);
assert.equal(row.display_name, "Test Promo Model (renamed)");
// …but the promo is untouched.
assert.equal(row.intro_until, "2026-08-31");
assert.equal(row.intro_input_per_mtok, 2);
assert.equal(row.intro_output_per_mtok, 10);
});
it("rejects a malformed intro_until without mutating the row", async () => {
const before = stmts.getPricing.get(PATTERN);
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 4,
output_per_mtok: 16,
intro_until: "August 31",
intro_input_per_mtok: 1,
},
});
assert.equal(res.status, 400);
const after = stmts.getPricing.get(PATTERN);
assert.deepEqual(after, before);
});
it("clears the promo and zeroes intro rates when intro_until is emptied", async () => {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 4,
output_per_mtok: 16,
intro_until: "",
// Even though rates are still sent, an empty date clears them so a
// re-added date later can't silently resurrect stale values.
intro_input_per_mtok: 2,
intro_output_per_mtok: 10,
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
assert.equal(row.intro_until, null);
assert.equal(row.intro_input_per_mtok, 0);
assert.equal(row.intro_output_per_mtok, 0);
});
});
describe("PUT /api/pricing — numeric rate validation", () => {
it("rejects a non-numeric rate without mutating the row", async () => {
const before = stmts.getPricing.get(PATTERN);
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: "abc",
output_per_mtok: 16,
},
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
assert.match(res.body.error.message, /input_per_mtok/);
assert.deepEqual(stmts.getPricing.get(PATTERN), before);
});
it("rejects a negative rate without mutating the row", async () => {
const before = stmts.getPricing.get(PATTERN);
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 4,
output_per_mtok: -1,
},
});
assert.equal(res.status, 400);
assert.match(res.body.error.message, /output_per_mtok/);
assert.deepEqual(stmts.getPricing.get(PATTERN), before);
});
it("rejects NaN in intro and fast rate fields too", async () => {
for (const field of ["fast_input_per_mtok", "intro_output_per_mtok"]) {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
intro_until: "2026-12-31",
[field]: "not-a-number",
},
});
assert.equal(res.status, 400, `${field} should be rejected`);
assert.match(res.body.error.message, new RegExp(field));
}
});
it("accepts numeric strings by coercing them to numbers", async () => {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: "4.5",
output_per_mtok: "18",
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
assert.equal(row.input_per_mtok, 4.5);
assert.equal(row.output_per_mtok, 18);
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* @file Tests for push notification API endpoints, covering subscription management and sending notifications. It verifies that the server correctly handles subscription creation, deletion, and sending push messages, ensuring proper validation and response formats.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
// Isolate test database
const TEST_DB = path.join(os.tmpdir(), `push-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
let server;
let BASE;
function request(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const bodyString = options.body ? JSON.stringify(options.body) : null;
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
...(bodyString ? { "Content-Length": Buffer.byteLength(bodyString) } : {}),
...options.headers,
},
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (bodyString) req.write(bodyString);
req.end();
});
}
function post(urlPath, body) {
return request(urlPath, { method: "POST", body });
}
function del(urlPath, body) {
return request(urlPath, { method: "DELETE", body });
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
const addr = server.address();
BASE = `http://127.0.0.1:${addr.port}`;
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
const fs = require("fs");
try {
fs.unlinkSync(TEST_DB);
} catch {}
});
describe("GET /api/push/vapid-public-key", () => {
it("returns a non-empty public key string", async () => {
const res = await request("/api/push/vapid-public-key");
assert.equal(res.status, 200);
assert.ok(typeof res.body.publicKey === "string");
assert.ok(res.body.publicKey.length > 0);
});
});
describe("POST /api/push/subscribe", () => {
it("stores a subscription and returns ok", async () => {
const res = await post("/api/push/subscribe", {
endpoint: "https://example.com/push/abc123",
keys: { p256dh: "dGVzdA==", auth: "dGVzdA==" },
});
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
});
it("returns 400 when endpoint is missing", async () => {
const res = await post("/api/push/subscribe", {
keys: { p256dh: "dGVzdA==", auth: "dGVzdA==" },
});
assert.equal(res.status, 400);
});
it("returns 400 when keys are missing", async () => {
const res = await post("/api/push/subscribe", {
endpoint: "https://example.com/push/abc123",
});
assert.equal(res.status, 400);
});
});
describe("DELETE /api/push/subscribe", () => {
it("removes a subscription and returns ok", async () => {
const endpoint = "https://example.com/push/to-delete";
await post("/api/push/subscribe", {
endpoint,
keys: { p256dh: "dGVzdA==", auth: "dGVzdA==" },
});
const res = await del("/api/push/subscribe", { endpoint });
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
});
it("returns 400 when endpoint is missing", async () => {
const res = await del("/api/push/subscribe", {});
assert.equal(res.status, 400);
});
});
describe("POST /api/push/send", () => {
it("returns ok when there are no subscriptions", async () => {
const res = await post("/api/push/send", {
title: "Test",
body: "Hello",
});
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
});
it("returns 400 when title is missing", async () => {
const res = await post("/api/push/send", { body: "Hello" });
assert.equal(res.status, 400);
});
});
+716
View File
@@ -0,0 +1,716 @@
/**
* @file Tests for the Remote Data Sources feature: input validation + command
* builders in server/lib/remote-sync.js, the /api/remote-sources route CRUD, and
* the source-scoped data filter threaded through the sessions/events/agents/
* stats/analytics endpoints. The actual SSH/rsync transfer is not exercised
* (that needs a live remote); everything up to and around it is.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
// Isolate the DB and disable background pollers/probes before loading server.
const TEST_DB = path.join(os.tmpdir(), `dashboard-remote-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const remoteSync = require("../lib/remote-sync");
const sourceFilter = require("../lib/source-filter");
let server;
let BASE;
function fetchJson(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const get = (p) => fetchJson(p);
const post = (p, body) => fetchJson(p, { method: "POST", body });
const patch = (p, body) => fetchJson(p, { method: "PATCH", body });
const del = (p) => fetchJson(p, { method: "DELETE" });
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// ── Validation ────────────────────────────────────────────────────────────────
describe("remote-sync validateSourceInput", () => {
it("accepts a valid full config and expands ~ in identity_file", () => {
const v = remoteSync.validateSourceInput({
label: "Dev Box",
host: "son@dev.local",
ssh_port: 22,
identity_file: "~/.ssh/id_ed25519",
remote_home: "~/.claude",
});
assert.equal(v.label, "Dev Box");
assert.equal(v.host, "son@dev.local");
assert.equal(v.sshPort, 22);
assert.ok(path.isAbsolute(v.identityFile));
assert.equal(v.remoteHome, "~/.claude");
});
it("accepts a config-alias host with no user", () => {
const v = remoteSync.validateSourceInput({ label: "x", host: "mybox" });
assert.equal(v.host, "mybox");
});
const rejects = [
[
"leading-dash host (ssh option injection)",
{ label: "x", host: "-oProxyCommand=evil" },
"INVALID_HOST",
],
["host with space", { label: "x", host: "a b" }, "INVALID_HOST"],
["host with ;", { label: "x", host: "a;rm -rf /" }, "INVALID_HOST"],
["host with : (breaks scp spec)", { label: "x", host: "a:b" }, "INVALID_HOST"],
["missing label", { host: "a" }, "INVALID_LABEL"],
["port out of range", { label: "x", host: "a", ssh_port: 99999 }, "INVALID_PORT"],
[
"remote_home with ..",
{ label: "x", host: "a", remote_home: "~/../etc" },
"INVALID_REMOTE_HOME",
],
[
"relative remote_home",
{ label: "x", host: "a", remote_home: "rel/path" },
"INVALID_REMOTE_HOME",
],
[
"identity_file with newline",
{ label: "x", host: "a", identity_file: "/a\nb" },
"INVALID_IDENTITY_FILE",
],
];
for (const [name, input, code] of rejects) {
it(`rejects ${name}`, () => {
assert.throws(
() => remoteSync.validateSourceInput(input),
(err) => err.code === code
);
});
}
it("allows hyphens inside an identity_file path", () => {
const v = remoteSync.validateSourceInput({
label: "x",
host: "a",
identity_file: "/home/u/.ssh/id-ed25519",
});
assert.equal(v.identityFile, "/home/u/.ssh/id-ed25519");
});
});
describe("remote-sync command builders", () => {
it("builds ssh option args with port + identity", async () => {
const args = await remoteSync.sshOptionArgs({ ssh_port: 2222, identity_file: "/k" });
assert.ok(args.includes("-p"));
assert.equal(args[args.indexOf("-p") + 1], "2222");
assert.ok(args.includes("-i"));
assert.equal(args[args.indexOf("-i") + 1], "/k");
assert.ok(args.includes("IdentitiesOnly=yes"));
});
it("builds scp option args with capital -P for port", async () => {
const args = await remoteSync.scpOptionArgs({ ssh_port: 2222, identity_file: "/k" });
assert.ok(args.includes("-P"));
assert.equal(args[args.indexOf("-P") + 1], "2222");
});
it("parses ssh -G output for identity agent discovery", () => {
const cfg = remoteSync.parseSshGOutput(
"hostname example.com\nidentityagent /tmp/agent.sock\nport 22\n"
);
assert.equal(cfg.identityagent, "/tmp/agent.sock");
assert.equal(cfg.port, "22");
});
it("buildSshChildEnv sets HOME and does not override SSH_AUTH_SOCK", () => {
const env = remoteSync.buildSshChildEnv();
assert.ok(env.HOME);
if (process.env.SSH_AUTH_SOCK) {
assert.equal(env.SSH_AUTH_SOCK, process.env.SSH_AUTH_SOCK);
}
});
it("identityAgentArgsFromConfig follows ssh -G only for concrete agent paths", () => {
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("none"), []);
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("SSH_AUTH_SOCK"), []);
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("/tmp/custom-agent.sock"), [
"-o",
"IdentityAgent=/tmp/custom-agent.sock",
]);
const home = os.homedir();
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("~/Library/agent.sock"), [
"-o",
`IdentityAgent=${path.join(home, "Library/agent.sock")}`,
]);
});
it("adds PowerShell and WSL probes for ~-rooted remote homes (Windows remotes)", () => {
const probes = remoteSync.connectionProbeCommands({ remote_home: "~/.claude" });
assert.equal(probes.length, 3);
assert.match(probes[0], /sh -c/);
assert.match(probes[0], /~\/\.claude\/projects/);
assert.match(probes[1], /powershell\.exe/);
assert.match(probes[1], /\.claude\\projects/);
assert.match(probes[2], /wsl\.exe/);
assert.match(probes[2], /~\/\.claude\/projects/);
});
it("uses only wsl.exe for wsl: remote homes", () => {
const probes = remoteSync.connectionProbeCommands({ remote_home: "wsl:~/.claude" });
assert.equal(probes.length, 1);
assert.match(probes[0], /wsl\.exe/);
});
it("connectionSuccessMessage reflects explicit vs auto-detected WSL", () => {
const wslProbe = "wsl.exe -e sh -c 'test -d ~/.claude/projects && echo CCAM_OK'";
assert.match(
remoteSync.connectionSuccessMessage({ remote_home: "wsl:~/.claude" }, wslProbe),
/wsl:~\/\.claude/
);
assert.doesNotMatch(
remoteSync.connectionSuccessMessage({ remote_home: "wsl:~/.claude" }, wslProbe),
/auto-detected/i
);
assert.match(
remoteSync.connectionSuccessMessage({ remote_home: null }, wslProbe),
/auto-detected/i
);
assert.match(
remoteSync.connectionSuccessMessage({ remote_home: null }, "sh -c 'echo CCAM_OK'"),
/Remote Claude Code history found/
);
});
it("accepts wsl: and UNC remote_home values", () => {
const wsl = remoteSync.validateSourceInput({
label: "WSL",
host: "u@win",
remote_home: "wsl:/home/hoang/.claude",
});
assert.equal(wsl.remoteHome, "wsl:/home/hoang/.claude");
assert.equal(
remoteSync.remoteProjectsPath({ remote_home: wsl.remoteHome }),
"wsl:/home/hoang/.claude/projects"
);
const unc = remoteSync.validateSourceInput({
label: "UNC",
host: "u@win",
remote_home: "//wsl.localhost/Ubuntu/home/hoang/.claude",
});
assert.equal(unc.remoteHome, "//wsl.localhost/Ubuntu/home/hoang/.claude");
assert.equal(
remoteSync.remoteProjectsPath({ remote_home: unc.remoteHome }),
"//wsl.localhost/Ubuntu/home/hoang/.claude/projects"
);
});
it("builds a wsl tar command for WSL-hosted Claude homes", () => {
assert.equal(
remoteSync.wslTarRemoteCmd("~/.claude"),
"wsl.exe -e sh -c 'tar -cC ~/.claude/projects .'"
);
});
it("adds cmd.exe probe only for Windows drive-letter remote homes", () => {
const probes = remoteSync.connectionProbeCommands({
remote_home: "C:/Users/hoang/.claude",
});
assert.equal(probes.length, 1);
assert.match(probes[0], /cmd \/c/);
assert.match(probes[0], /C:\\Users\\hoang\\.claude\\projects/);
});
it("uses sh probe for POSIX absolute remote homes", () => {
const probes = remoteSync.connectionProbeCommands({ remote_home: "/opt/cc" });
assert.deepEqual(probes, [
"sh -c 'test -d /opt/cc/projects && echo CCAM_OK || echo CCAM_NO_DIR'",
]);
});
it("accepts Windows-style remote_home with forward slashes", () => {
const v = remoteSync.validateSourceInput({
label: "Win",
host: "u@win",
remote_home: "C:/Users/hoang/.claude",
});
assert.equal(v.remoteHome, "C:/Users/hoang/.claude");
assert.equal(
remoteSync.remoteProjectsPath({ remote_home: v.remoteHome }),
"C:/Users/hoang/.claude/projects"
);
assert.equal(
remoteSync.scpRemoteSpec({ host: "u@win", remote_home: v.remoteHome }),
"u@win:C:/Users/hoang/.claude/projects/."
);
});
it("expands tilde in ssh -G IdentityAgent paths and strips quotes", () => {
const home = os.homedir();
assert.equal(
remoteSync.expandSshConfigPath("~/Library/agent.sock"),
path.join(home, "Library/agent.sock")
);
assert.equal(remoteSync.expandSshConfigPath('"/tmp/quoted.sock"'), "/tmp/quoted.sock");
assert.equal(remoteSync.expandSshConfigPath("/tmp/a"), "/tmp/a");
});
it("sshConfigFileArgs points at user config when present", () => {
const args = remoteSync.sshConfigFileArgs();
const cfg = path.join(os.homedir(), ".ssh", "config");
if (fs.existsSync(cfg)) {
assert.deepEqual(args, ["-F", cfg]);
} else {
assert.deepEqual(args, []);
}
});
it("treats blank identity_file as null", () => {
const v = remoteSync.validateSourceInput({
label: "x",
host: "a",
identity_file: " ",
});
assert.equal(v.identityFile, null);
});
it("detects legacy scp protocol errors for -O retry", () => {
assert.equal(
remoteSync.isLegacyScpProtocolError("subsystem request failed on channel 0"),
true
);
assert.equal(remoteSync.isLegacyScpProtocolError("Permission denied"), false);
});
it("strips ANSI escapes from command output", () => {
assert.equal(remoteSync.stripAnsi("\u001b[31;1mscp: not found\u001b[0m"), "scp: not found");
});
it("resolves ssh/scp binaries on Windows when OpenSSH is in System32", () => {
const prev = process.platform;
const prevWin = process.env.WINDIR;
try {
Object.defineProperty(process, "platform", { value: "win32" });
process.env.WINDIR = process.env.WINDIR || "C:\\Windows";
const ssh = remoteSync.resolveSshBinary("ssh");
assert.match(ssh, /ssh\.exe$/i);
} finally {
Object.defineProperty(process, "platform", { value: prev });
if (prevWin === undefined) delete process.env.WINDIR;
else process.env.WINDIR = prevWin;
}
});
it("defaults the remote projects path to ~/.claude/projects", () => {
assert.equal(remoteSync.remoteProjectsPath({}), "~/.claude/projects");
assert.equal(remoteSync.remoteProjectsPath({ remote_home: "/opt/cc" }), "/opt/cc/projects");
});
it("identifies top-level session ids in a mirrored tree (skips subagents)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-staged-"));
const proj = path.join(dir, "-Users-x-proj");
fs.mkdirSync(path.join(proj, "sess-1", "subagents"), { recursive: true });
fs.writeFileSync(path.join(proj, "sess-1.jsonl"), "{}\n");
fs.writeFileSync(path.join(proj, "sess-2.jsonl"), "{}\n");
fs.writeFileSync(path.join(proj, "sess-1", "subagents", "agent-abc.jsonl"), "{}\n");
const ids = remoteSync.stagedSessionIds(dir).sort();
assert.deepEqual(ids, ["sess-1", "sess-2"]);
fs.rmSync(dir, { recursive: true, force: true });
});
});
// ── source-filter helper ────────────────────────────────────────────────────
describe("source-filter helper", () => {
it("parses the sources csv, deduped; empty/absent → null", () => {
assert.deepEqual(sourceFilter.parseSources({ query: { sources: "local, a ,a,," } }), [
"local",
"a",
]);
assert.equal(sourceFilter.parseSources({ query: {} }), null);
assert.equal(sourceFilter.parseSources({ query: { sources: " ,, " } }), null);
});
it("builds a column clause and a subquery clause", () => {
assert.deepEqual(sourceFilter.sourceColumnClause(["local", "a"]), {
clause: "s.source IN (?,?)",
params: ["local", "a"],
});
assert.deepEqual(sourceFilter.sessionIdInSourcesClause(["local"], "e.session_id"), {
clause: "e.session_id IN (SELECT id FROM sessions WHERE source IN (?))",
params: ["local"],
});
assert.deepEqual(sourceFilter.sourceColumnClause(null), { clause: "", params: [] });
});
});
// ── Route CRUD ──────────────────────────────────────────────────────────────
describe("/api/remote-sources CRUD", () => {
let createdId;
it("starts empty", async () => {
const res = await get("/api/remote-sources");
assert.equal(res.status, 200);
assert.deepEqual(res.body.sources, []);
});
it("creates a source", async () => {
const res = await post("/api/remote-sources", { label: "Dev", host: "son@dev", ssh_port: 22 });
assert.equal(res.status, 201);
assert.equal(res.body.source.label, "Dev");
assert.equal(res.body.source.host, "son@dev");
assert.equal(res.body.source.enabled, true);
assert.equal(res.body.source.status, "idle");
assert.ok(res.body.source.id.startsWith("src_"));
createdId = res.body.source.id;
});
it("rejects an invalid host with a 400 + structured error", async () => {
const res = await post("/api/remote-sources", { label: "Bad", host: "-oProxyCommand=x" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_HOST");
});
it("patches label + enabled, leaving other fields intact", async () => {
const res = await patch(`/api/remote-sources/${createdId}`, {
label: "Renamed",
enabled: false,
});
assert.equal(res.status, 200);
assert.equal(res.body.source.label, "Renamed");
assert.equal(res.body.source.enabled, false);
assert.equal(res.body.source.ssh_port, 22); // unchanged
});
it("404s for an unknown id", async () => {
const res = await patch("/api/remote-sources/src_nope", { label: "x" });
assert.equal(res.status, 404);
});
it("delete without purge detaches its sessions back to local", async () => {
// Attach a session to the source, then delete without purge.
stmts.insertSession.run("rs-detach-1", "s", "active", "/x", "claude-opus-4-8", null);
stmts.setSessionSource.run(createdId, "rs-detach-1");
const res = await del(`/api/remote-sources/${createdId}`);
assert.equal(res.status, 200);
assert.equal(res.body.purged, 0);
assert.equal(stmts.getSession.get("rs-detach-1").source, "local");
assert.equal(stmts.getRemoteSource.get(createdId), undefined);
});
it("delete with purge removes the source's sessions", async () => {
const c = await post("/api/remote-sources", { label: "P", host: "p@h" });
const id = c.body.source.id;
stmts.insertSession.run("rs-purge-1", "s", "active", "/x", "claude-opus-4-8", null);
stmts.setSessionSource.run(id, "rs-purge-1");
const res = await del(`/api/remote-sources/${id}?purge=true`);
assert.equal(res.status, 200);
assert.equal(res.body.purged, 1);
assert.equal(stmts.getSession.get("rs-purge-1"), undefined);
});
});
// ── Source-scoped data endpoints ──────────────────────────────────────────────
describe("source scoping across data endpoints", () => {
before(async () => {
// A local session and a remote-tagged session, each with one event.
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "scope-local", cwd: "/local" },
});
await post("/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "scope-remote", cwd: "/remote" },
});
stmts.setSessionSource.run("src_scope", "scope-remote");
});
it("facets lists distinct sources (local + tagged)", async () => {
const res = await get("/api/sessions/facets");
assert.ok(res.body.sources.includes("local"));
assert.ok(res.body.sources.includes("src_scope"));
});
it("sessions?sources=local excludes the remote session", async () => {
const res = await get("/api/sessions?sources=local&limit=1000");
const ids = res.body.sessions.map((s) => s.id);
assert.ok(ids.includes("scope-local"));
assert.ok(!ids.includes("scope-remote"));
});
it("sessions?sources=src_scope returns only the remote session", async () => {
const res = await get("/api/sessions?sources=src_scope&limit=1000");
const ids = res.body.sessions.map((s) => s.id);
assert.deepEqual(ids, ["scope-remote"]);
assert.equal(res.body.sessions[0].source, "src_scope");
});
it("sessions with no sources param returns both", async () => {
const res = await get("/api/sessions?limit=1000");
const ids = res.body.sessions.map((s) => s.id);
assert.ok(ids.includes("scope-local") && ids.includes("scope-remote"));
});
it("stats respects the source scope", async () => {
const all = await get("/api/stats");
const local = await get("/api/stats?sources=local");
const remote = await get("/api/stats?sources=src_scope");
assert.ok(all.body.total_sessions >= 2);
assert.equal(
local.body.total_sessions + remote.body.total_sessions <= all.body.total_sessions,
true
);
// The remote scope sees exactly its one tagged session.
assert.equal(remote.body.total_sessions, 1);
});
it("analytics respects the source scope", async () => {
const remote = await get("/api/analytics?sources=src_scope");
assert.equal(remote.body.overview.total_sessions, 1);
});
it("events?sources=src_scope only returns the remote session's events", async () => {
const res = await get("/api/events?sources=src_scope&limit=1000");
assert.ok(res.body.events.every((e) => e.session_id === "scope-remote"));
});
it("agents?sources=local excludes the remote session's agents", async () => {
const res = await get("/api/agents?sources=local");
assert.ok(res.body.agents.every((a) => a.session_id !== "scope-remote"));
});
it("pricing cost respects the source scope (regression: total cost was global)", async () => {
// Equal usage on the local + the remote session; the default claude-opus-4-8
// pricing rule prices it. Before the fix, /pricing/cost ignored `sources`, so
// every scope returned the same global total and the Dashboard cost never
// moved when the scope changed.
stmts.upsertTokenUsage.run("scope-local", "claude-opus-4-8", 1_000_000, 0, 0, 0);
stmts.upsertTokenUsage.run("scope-remote", "claude-opus-4-8", 1_000_000, 0, 0, 0);
const all = await get("/api/pricing/cost");
const local = await get("/api/pricing/cost?sources=local");
const remote = await get("/api/pricing/cost?sources=src_scope");
assert.ok(all.body.total_cost > 0, "some cost recorded across all sources");
assert.ok(local.body.total_cost > 0, "local scope has cost");
assert.ok(remote.body.total_cost > 0, "remote scope has cost");
// The fix: each scope is strictly less than the combined total.
assert.ok(local.body.total_cost < all.body.total_cost, "local scope excludes remote cost");
assert.ok(remote.body.total_cost < all.body.total_cost, "remote scope excludes local cost");
// The two disjoint scopes partition the whole (no other opus-4-8 usage here).
assert.ok(
Math.abs(local.body.total_cost + remote.body.total_cost - all.body.total_cost) < 1e-6,
"local + remote cost sums to the unscoped total"
);
});
});
describe("/api/remote-sources session_count", () => {
it("reports the live number of sessions attributed to each source", async () => {
const c = await post("/api/remote-sources", { label: "Counted", host: "c@h" });
const id = c.body.source.id;
// A freshly-added source has no sessions yet.
assert.equal(c.body.source.session_count ?? 0, 0);
// Tag two sessions to it, then confirm the list reflects the count.
stmts.insertSession.run("rs-count-1", "s", "active", "/x", "claude-opus-4-8", null);
stmts.insertSession.run("rs-count-2", "s", "active", "/y", "claude-opus-4-8", null);
stmts.setSessionSource.run(id, "rs-count-1");
stmts.setSessionSource.run(id, "rs-count-2");
const list = await get("/api/remote-sources");
const row = list.body.sources.find((s) => s.id === id);
assert.equal(row.session_count, 2);
});
});
// ── Remote session status reconciliation ─────────────────────────────────────
// A remote session gets NO live hooks and is excluded from every local liveness/
// staleness sweep, so its active/completed state is driven solely by whether its
// mirrored transcript is still advancing. These exercise that reconciliation
// directly against a staged tree with controlled mtimes.
describe("reconcileRemoteSessionStatus", () => {
const dbModule = require("../db");
const SRC = { id: "src_recon" };
let stageRoot;
before(() => {
stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-recon-"));
});
after(() => {
try {
fs.rmSync(stageRoot, { recursive: true, force: true });
} catch {
/* ignore */
}
});
function stageSession(id, ageMs, contentLine = "{}") {
const proj = path.join(stageRoot, "-Users-x-proj");
fs.mkdirSync(proj, { recursive: true });
const f = path.join(proj, `${id}.jsonl`);
fs.writeFileSync(f, `${contentLine}\n`);
const t = new Date(Date.now() - ageMs);
fs.utimesSync(f, t, t);
return f;
}
it("heals a wrongly-completed remote session whose mirror is still fresh", () => {
stmts.insertSession.run(
"recon-fresh",
"s",
"completed",
"/home/ubuntu/matroid",
"claude-opus-4-8",
null
);
stmts.setSessionSource.run(SRC.id, "recon-fresh");
stmts.insertAgent.run(
"recon-fresh-main",
"recon-fresh",
"Main",
"main",
null,
"completed",
null,
null,
null
);
stageSession("recon-fresh", 1_000); // 1s old → still running
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
assert.equal(stmts.getSession.get("recon-fresh").status, "active");
assert.equal(stmts.getSession.get("recon-fresh").ended_at, null, "ended_at cleared on heal");
assert.equal(stmts.getAgent.get("recon-fresh-main").status, "waiting");
});
it("completes an active remote session whose mirror has gone stale", () => {
stmts.insertSession.run(
"recon-stale",
"s",
"active",
"/home/ubuntu/other",
"claude-opus-4-8",
null
);
stmts.setSessionSource.run(SRC.id, "recon-stale");
stmts.insertAgent.run(
"recon-stale-main",
"recon-stale",
"Main",
"main",
null,
"waiting",
null,
null,
null
);
stageSession("recon-stale", 20 * 60 * 1000); // 20 min idle → ended
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
assert.equal(stmts.getSession.get("recon-stale").status, "completed");
assert.ok(stmts.getSession.get("recon-stale").ended_at, "ended_at stamped");
assert.equal(stmts.getAgent.get("recon-stale-main").status, "completed");
});
it("completes an active session when mtime is fresh but transcript content is stale", () => {
stmts.insertSession.run(
"recon-touch",
"s",
"active",
"/home/ubuntu/touched",
"claude-opus-4-8",
null
);
stmts.setSessionSource.run(SRC.id, "recon-touch");
stmts.insertAgent.run(
"recon-touch-main",
"recon-touch",
"Main",
"main",
null,
"waiting",
null,
null,
null
);
const stale = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
stageSession("recon-touch", 1_000, `{"timestamp":"${stale}"}`);
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
assert.equal(stmts.getSession.get("recon-touch").status, "completed");
assert.equal(stmts.getAgent.get("recon-touch-main").status, "completed");
});
it("never touches a session owned by a different source", () => {
stmts.insertSession.run(
"recon-other",
"s",
"active",
"/home/ubuntu/z",
"claude-opus-4-8",
null
);
stmts.setSessionSource.run("src_different", "recon-other");
stageSession("recon-other", 20 * 60 * 1000); // stale, but not this source's
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
assert.equal(stmts.getSession.get("recon-other").status, "active");
});
});
describe("POST /api/remote-sources/sync-all", () => {
it("syncs only enabled sources and isolates per-source outcomes", async () => {
// Disable every existing source so this exercises the wiring without any
// real SSH/rsync shell-out (nothing enabled → nothing to pull).
const { body } = await get("/api/remote-sources");
for (const s of body.sources) {
if (s.enabled) await patch(`/api/remote-sources/${s.id}`, { enabled: false });
}
const res = await post("/api/remote-sources/sync-all");
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.synced, 0);
assert.deepEqual(res.body.results, []);
});
it("does not collide with the /:id/sync route", async () => {
// "sync-all" is a single path segment, so it must not be treated as an :id.
const res = await post("/api/remote-sources/sync-all");
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.results));
});
});
+543
View File
@@ -0,0 +1,543 @@
/**
* @file run.test.js
* @description Tests for the Run feature: spawner injection, route
* validation, same-origin guard, cwd suggestions, resume validation,
* envelope storage / attach, and end-to-end handle lifecycle. Uses a fake
* child (PassThrough streams + EventEmitter) so we never invoke the real
* `claude` binary.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const http = require("node:http");
const { PassThrough } = require("node:stream");
const { EventEmitter } = require("node:events");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { createApp } = require("../index");
const runs = require("../lib/run-spawner");
const runRoute = require("../routes/run");
let server;
let BASE;
function fetchJson(p, opts = {}) {
return new Promise((resolve, reject) => {
const url = new URL(p, BASE);
const headers = { ...(opts.headers || {}) };
let body;
if (opts.body !== undefined) {
body = Buffer.from(JSON.stringify(opts.body));
headers["Content-Type"] = "application/json";
headers["Content-Length"] = body.length;
}
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: opts.method || "GET",
headers,
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8");
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
parsed = raw;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (body) req.write(body);
req.end();
});
}
function makeFakeChild() {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = new PassThrough();
child.killed = false;
child.kill = function (sig) {
this.killed = true;
setImmediate(() => this.emit("exit", sig === "SIGTERM" ? 143 : 0, sig || null));
};
return child;
}
describe("/api/run", () => {
before(async () => {
const app = createApp();
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
const port = server.address().port;
BASE = `http://127.0.0.1:${port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
// The SQLite DB lives under TMP and better-sqlite3 holds it open, so on
// Windows rmSync hits EPERM (can't remove a dir with an open handle).
// maxRetries covers transient locks; the try/catch makes the rest
// best-effort — a leftover temp dir must not fail the suite (the OS
// reclaims os.tmpdir()).
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup */
}
});
beforeEach(() => {
runs.__reset();
});
it("rejects cross-origin browser requests", async () => {
const { status, body } = await fetchJson("/api/run", {
headers: { Origin: "http://evil.example.com" },
});
assert.equal(status, 403);
assert.equal(body.error.code, "EBADORIGIN");
});
it("allows requests with no Origin (CLI/curl)", async () => {
const { status, body } = await fetchJson("/api/run");
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
});
it("allows localhost Origin", async () => {
const { status } = await fetchJson("/api/run", {
headers: { Origin: "http://localhost:5173" },
});
assert.equal(status, 200);
});
it("POST / requires prompt", async () => {
const { status, body } = await fetchJson("/api/run", { method: "POST", body: {} });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADPROMPT");
});
it("POST / rejects non-existent cwd", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "headless", cwd: "/nope/does/not/exist" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("POST / rejects relative cwd", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "headless", cwd: "./relative" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("GET /:id returns 404 for unknown id", async () => {
const { status, body } = await fetchJson("/api/run/does-not-exist");
assert.equal(status, 404);
assert.equal(body.error.code, "ENOTFOUND");
});
it("DELETE /:id returns 404 for unknown id", async () => {
const { status } = await fetchJson("/api/run/does-not-exist", { method: "DELETE" });
assert.equal(status, 404);
});
it("POST /:id/message rejects empty text", async () => {
const { status, body } = await fetchJson("/api/run/x/message", {
method: "POST",
body: {},
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADINPUT");
});
// ── /api/run/cwds suggestions ─────────────────────────────────────
it("GET /cwds returns dashboard + home suggestions with absolute paths", async () => {
const { status, body } = await fetchJson("/api/run/cwds");
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
const kinds = body.items.map((i) => i.kind);
assert.ok(kinds.includes("dashboard"), "dashboard cwd present");
assert.ok(kinds.includes("home"), "home present");
for (const it of body.items) {
assert.equal(typeof it.path, "string");
// path.isAbsolute is platform-aware: "/x" on POSIX, "C:\\x" on Windows.
assert.ok(path.isAbsolute(it.path), "absolute path");
assert.equal(typeof it.label, "string");
}
});
// ── /api/run/binary probe ─────────────────────────────────────────
it("GET /binary returns shape { found, path }", async () => {
const { status, body } = await fetchJson("/api/run/binary");
assert.equal(status, 200);
assert.equal(typeof body.found, "boolean");
if (body.found) assert.equal(typeof body.path, "string");
});
// ── Resume validation ─────────────────────────────────────────────
it("POST / rejects bad resumeSessionId format", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "conversation", resumeSessionId: "x" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADSESSION");
});
it("POST / rejects unknown effort level", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "conversation", effort: "ludicrous" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADEFFORT");
});
it("POST / rejects resumeSessionId with headless mode", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: {
prompt: "hi",
mode: "headless",
resumeSessionId: "deadbeef-cafe-1234-5678-feedfacefeed",
},
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADMODE");
});
// ── HTTP GET /:id?envelopes=1 (attach payload) ────────────────────
it("GET /:id?envelopes=1 returns the in-memory envelope log", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"sX"}\n`);
await new Promise((r) => setImmediate(r));
const { status, body } = await fetchJson(`/api/run/${handle.id}?envelopes=1`);
assert.equal(status, 200);
assert.ok(Array.isArray(body.envelopes));
assert.equal(body.envelopes.length, 1);
assert.equal(body.envelopes[0].type, "system");
});
it("GET /files returns paths matching q, skipping node_modules", async () => {
// Build a tiny fixture under tmp so the test is hermetic.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "run-files-"));
fs.mkdirSync(path.join(tmp, "src"));
fs.mkdirSync(path.join(tmp, "node_modules", "leftover-pkg"), { recursive: true });
fs.writeFileSync(path.join(tmp, "README.md"), "x");
fs.writeFileSync(path.join(tmp, "src", "index.ts"), "x");
fs.writeFileSync(path.join(tmp, "node_modules", "leftover-pkg", "x.js"), "x");
try {
const { status, body } = await fetchJson(
`/api/run/files?cwd=${encodeURIComponent(tmp)}&q=index`
);
assert.equal(status, 200);
assert.deepEqual(body.items.sort(), ["src/index.ts"]);
// No q → returns top-level files (excluding node_modules)
const all = await fetchJson(`/api/run/files?cwd=${encodeURIComponent(tmp)}`);
assert.ok(all.body.items.includes("README.md"));
assert.ok(!all.body.items.some((p) => p.startsWith("node_modules")));
} finally {
try {
fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup (Windows may hold a handle) */
}
}
});
it("GET /files rejects missing/invalid cwd", async () => {
const { status, body } = await fetchJson("/api/run/files?cwd=/does/not/exist");
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("GET /:id without ?envelopes returns metadata only", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
const { body } = await fetchJson(`/api/run/${handle.id}`);
assert.equal(body.envelopes, undefined);
assert.equal(body.envelopeCount, 1);
});
});
describe("run-spawner unit", () => {
beforeEach(() => {
runs.__reset();
});
it("injected child parses stream-json envelopes and broadcasts", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(
`{"type":"system","subtype":"init","session_id":"sess-abc","model":"opus"}\n`
);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`);
// Allow the line parser to flush
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "running");
assert.equal(live.sessionId, "sess-abc");
assert.equal(live.envelopeCount, 2);
});
it("sendInput writes a stream-json envelope to stdin", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
// Force into running state via a parsed envelope first
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`);
await new Promise((r) => setImmediate(r));
const chunks = [];
fake.stdin.on("data", (c) => chunks.push(c.toString()));
runs.sendInput(handle.id, "follow-up");
await new Promise((r) => setImmediate(r));
const written = chunks.join("");
const lines = written.trim().split("\n");
const obj = JSON.parse(lines[lines.length - 1]);
assert.equal(obj.type, "user");
assert.equal(obj.message.content, "follow-up");
});
it("sendInput rejects on headless handles", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "headless" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, "x"), /only conversation mode/);
});
it("kill marks handle as killed and emits exit", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
runs.killRun(handle.id);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "killed");
});
it("escalates to SIGKILL when SIGTERM was delivered but the child has not exited", async () => {
const fake = makeFakeChild();
const signals = [];
fake.kill = function (signal) {
this.killed = true;
signals.push(signal);
if (signal === "SIGKILL") setImmediate(() => this.emit("exit", 137, signal));
return true;
};
const handle = runs.__injectChildForTest({ child: fake });
const originalSetTimeout = global.setTimeout;
global.setTimeout = (callback, delay, ...args) => {
if (delay === 5000) {
callback(...args);
return { unref: () => {} };
}
return originalSetTimeout(callback, delay, ...args);
};
try {
runs.killRun(handle.id);
} finally {
global.setTimeout = originalSetTimeout;
}
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
assert.notEqual(runs.getRun(handle.id).actualExitedAt, null);
});
it("exit with code 0 marks completed", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "completed");
assert.equal(live.exitCode, 0);
});
it("exit with non-zero code marks error", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 1, null);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "error");
});
it("malformed JSON lines do not crash; go to stderr buffer", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.stdout.write("not valid json\n");
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.match(live.stderrTail, /parse-error/);
});
it("listRuns returns handles sorted newest first", async () => {
const a = runs.__injectChildForTest({ child: makeFakeChild() });
await new Promise((r) => setTimeout(r, 5));
const b = runs.__injectChildForTest({ child: makeFakeChild() });
const list = runs.listRuns();
assert.equal(list[0].id, b.id);
assert.equal(list[1].id, a.id);
});
});
describe("sameOriginGuard helper", () => {
it("loopback Origin passes", () => {
const next = () => "OK";
const res = {};
const result = runRoute.__sameOriginGuard(
{ headers: { origin: "http://127.0.0.1:4820" } },
res,
next
);
assert.equal(result, "OK");
});
it("missing Origin passes (CLI use case)", () => {
const next = () => "OK";
const result = runRoute.__sameOriginGuard({ headers: {} }, {}, next);
assert.equal(result, "OK");
});
it("non-loopback Origin is blocked", () => {
let captured = null;
const res = {
status(code) {
captured = { code };
return this;
},
json(body) {
captured.body = body;
return this;
},
};
runRoute.__sameOriginGuard({ headers: { origin: "http://attacker.com" } }, res, () => {});
assert.equal(captured.code, 403);
assert.equal(captured.body.error.code, "EBADORIGIN");
});
});
describe("run-spawner extras", () => {
beforeEach(() => {
runs.__reset();
});
it("getRun (no opts) returns metadata only — no envelopes field", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.envelopeCount, 2);
assert.equal(live.envelopes, undefined);
});
it("getRun({includeEnvelopes:true}) returns the in-memory log", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"x"}]}}\n`);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id, { includeEnvelopes: true });
assert.ok(Array.isArray(live.envelopes));
assert.equal(live.envelopes.length, 2);
assert.equal(live.envelopes[0].type, "system");
});
it("listRuns surfaces resumeSessionId (null for fresh)", async () => {
runs.__injectChildForTest({ child: makeFakeChild(), mode: "conversation" });
const list = runs.listRuns();
assert.equal(list.length, 1);
assert.equal(list[0].resumeSessionId, null);
});
it("killRun is idempotent on already-completed handles", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
assert.equal(runs.getRun(handle.id).status, "completed");
// Second kill on a completed handle should be a safe no-op (returns true).
assert.equal(runs.killRun(handle.id), true);
assert.equal(runs.getRun(handle.id).status, "completed");
});
it("killRun returns false for an unknown id", () => {
assert.equal(runs.killRun("does-not-exist"), false);
});
it("sendInput throws ENOTFOUND for unknown id", () => {
assert.throws(() => runs.sendInput("nope", "hi"), /not found/);
});
it("sendInput throws ENOTRUNNING when handle has already exited", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, "x"), /run is (completed|killed|error)/);
});
it("sendInput rejects empty text", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, ""), /text is required/);
});
it("envelope log is capped at 500 entries", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
let line = "";
for (let i = 0; i < 600; i++) line += `{"type":"assistant","i":${i}}\n`;
fake.stdout.write(line);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id, { includeEnvelopes: true });
assert.equal(live.envelopeCount, 600);
assert.equal(live.envelopes.length, 500);
// The cap drops the OLDEST entries — last entry should be the latest.
assert.equal(live.envelopes[live.envelopes.length - 1].i, 599);
});
it("getMaxConcurrent respects RUN_MAX_CONCURRENT env override", () => {
const orig = process.env.RUN_MAX_CONCURRENT;
try {
process.env.RUN_MAX_CONCURRENT = "7";
assert.equal(runs.getMaxConcurrent(), 7);
process.env.RUN_MAX_CONCURRENT = "garbage";
assert.ok(runs.getMaxConcurrent() >= 1, "falls back to default on non-numeric");
delete process.env.RUN_MAX_CONCURRENT;
assert.ok(runs.getMaxConcurrent() >= 1);
} finally {
if (orig != null) process.env.RUN_MAX_CONCURRENT = orig;
else delete process.env.RUN_MAX_CONCURRENT;
}
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* @file security.test.js
* @description Tests the network-exposure hardening (GHSA-gr74-4xfh-6jw9):
* loopback-by-default bind, Host-header allowlist (anti DNS-rebinding),
* loopback-only CORS, and the optional bearer-token gate on /api/* + WebSocket.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const sec = require("../lib/security");
const ENV_KEYS = ["DASHBOARD_HOST", "DASHBOARD_ALLOWED_HOSTS", "DASHBOARD_TOKEN"];
afterEach(() => {
for (const k of ENV_KEYS) delete process.env[k];
});
function mockRes() {
return {
statusCode: 0,
body: null,
status(c) {
this.statusCode = c;
return this;
},
json(b) {
this.body = b;
return this;
},
};
}
describe("resolveHost", () => {
it("defaults to loopback (127.0.0.1)", () => {
assert.equal(sec.resolveHost(), "127.0.0.1");
});
it("honors an explicit DASHBOARD_HOST opt-in", () => {
process.env.DASHBOARD_HOST = "0.0.0.0";
assert.equal(sec.resolveHost(), "0.0.0.0");
assert.equal(sec.isLoopbackHostname("0.0.0.0"), true); // treated as loopback-equiv for Host checks
});
});
describe("Host allowlist (DNS-rebinding defense)", () => {
it("allows loopback Host headers", () => {
assert.equal(sec.isHostAllowed("localhost:4820"), true);
assert.equal(sec.isHostAllowed("127.0.0.1:4820"), true);
assert.equal(sec.isHostAllowed("[::1]:4820"), true);
assert.equal(sec.isHostAllowed(""), true); // missing Host (HTTP/1.0 / local tooling)
});
it("rejects a rebound attacker Host", () => {
assert.equal(sec.isHostAllowed("evil.example"), false);
assert.equal(sec.isHostAllowed("attacker.example:4820"), false);
});
it("permits operator-allowlisted hostnames", () => {
process.env.DASHBOARD_ALLOWED_HOSTS = "dash.internal, 192.168.1.50";
assert.equal(sec.isHostAllowed("dash.internal:4820"), true);
assert.equal(sec.isHostAllowed("192.168.1.50:4820"), true);
assert.equal(sec.isHostAllowed("evil.example"), false);
});
it("hostGuard middleware 403s a disallowed Host", () => {
const res = mockRes();
let nexted = false;
sec.hostGuard({ headers: { host: "evil.example" } }, res, () => (nexted = true));
assert.equal(nexted, false);
assert.equal(res.statusCode, 403);
assert.equal(res.body.error.code, "EBADHOST");
});
it("hostGuard middleware allows loopback", () => {
let nexted = false;
sec.hostGuard({ headers: { host: "localhost:4820" } }, mockRes(), () => (nexted = true));
assert.equal(nexted, true);
});
});
describe("CORS", () => {
const allowed = (origin) =>
new Promise((resolve) => sec.corsOptions().origin(origin, (_e, ok) => resolve(ok)));
it("allows same-origin / no-Origin (curl, the server's own client)", async () => {
assert.equal(await allowed(undefined), true);
});
it("allows loopback origins", async () => {
assert.equal(await allowed("http://localhost:5173"), true);
assert.equal(await allowed("http://127.0.0.1:4820"), true);
});
it("refuses cross-origin pages", async () => {
assert.equal(await allowed("https://evil.example"), false);
});
});
describe("token gate (optional, opt-in)", () => {
it("is a no-op when DASHBOARD_TOKEN is unset (default)", () => {
let nexted = false;
sec.tokenGuard({ path: "/stats", headers: {}, query: {} }, mockRes(), () => (nexted = true));
assert.equal(nexted, true);
});
it("rejects a missing/invalid token when configured", () => {
process.env.DASHBOARD_TOKEN = "s3cret";
const res = mockRes();
let nexted = false;
sec.tokenGuard({ path: "/stats", headers: {}, query: {} }, res, () => (nexted = true));
assert.equal(nexted, false);
assert.equal(res.statusCode, 401);
assert.equal(res.body.error.code, "EUNAUTHORIZED");
const res2 = mockRes();
sec.tokenGuard(
{ path: "/stats", headers: { "x-dashboard-token": "wrong" }, query: {} },
res2,
() => {}
);
assert.equal(res2.statusCode, 401);
});
it("accepts a correct token via header, bearer, or query", () => {
process.env.DASHBOARD_TOKEN = "s3cret";
const ok = (req) => {
let nexted = false;
sec.tokenGuard(req, mockRes(), () => (nexted = true));
return nexted;
};
assert.equal(
ok({ path: "/stats", headers: { "x-dashboard-token": "s3cret" }, query: {} }),
true
);
assert.equal(
ok({ path: "/stats", headers: { authorization: "Bearer s3cret" }, query: {} }),
true
);
assert.equal(ok({ path: "/stats", headers: {}, query: { token: "s3cret" } }), true);
});
it("exempts health, docs, and local hook ingestion even when a token is set", () => {
process.env.DASHBOARD_TOKEN = "s3cret";
const ok = (path) => {
let nexted = false;
sec.tokenGuard({ path, headers: {}, query: {} }, mockRes(), () => (nexted = true));
return nexted;
};
assert.equal(ok("/health"), true);
assert.equal(ok("/openapi.json"), true);
assert.equal(ok("/hooks/event"), true);
assert.equal(ok("/sessions/abc"), false); // still gated
});
});
describe("WebSocket auth", () => {
it("allows any upgrade when no token is configured", () => {
assert.equal(sec.isWebSocketAuthorized({ url: "/ws", headers: {} }), true);
});
it("requires a matching ?token= when configured", () => {
process.env.DASHBOARD_TOKEN = "s3cret";
assert.equal(sec.isWebSocketAuthorized({ url: "/ws?token=s3cret", headers: {} }), true);
assert.equal(sec.isWebSocketAuthorized({ url: "/ws?token=nope", headers: {} }), false);
assert.equal(sec.isWebSocketAuthorized({ url: "/ws", headers: {} }), false);
assert.equal(
sec.isWebSocketAuthorized({ url: "/ws", headers: { "x-dashboard-token": "s3cret" } }),
true
);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* @file Tests for multi-server discovery and hook-ingest deduplication by
* SQLite data directory (`server/lib/server-info.js`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");
const STAMP = `server-info-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR_A = path.join(TMP, "data-a");
const DATA_DIR_B = path.join(TMP, "data-b");
function freshModule(dataDir) {
delete require.cache[require.resolve("../lib/server-info")];
delete require.cache[require.resolve("../lib/claude-home")];
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = dataDir;
return require("../lib/server-info");
}
function writeDiscovery(servers) {
const infoPath = path.join(CLAUDE_HOME, ".agent-dashboard.json");
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
const recent = servers.reduce((a, b) =>
Date.parse(b.startedAt) > Date.parse(a.startedAt) ? b : a
);
fs.writeFileSync(
infoPath,
JSON.stringify(
{ port: recent.port, pid: recent.pid, startedAt: recent.startedAt, servers },
null,
2
)
);
}
describe("server-info hook ingest deduplication", () => {
beforeEach(() => {
fs.rmSync(TMP, { recursive: true, force: true });
fs.mkdirSync(DATA_DIR_A, { recursive: true });
fs.mkdirSync(DATA_DIR_B, { recursive: true });
});
afterEach(() => {
delete process.env.CLAUDE_DASHBOARD_PORT;
fs.rmSync(TMP, { recursive: true, force: true });
delete require.cache[require.resolve("../lib/server-info")];
delete require.cache[require.resolve("../lib/claude-home")];
});
it("dedupes hook targets when two live servers share one dataDir", () => {
const mod = freshModule(DATA_DIR_A);
writeDiscovery([
{
port: 4820,
pid: process.pid,
startedAt: "2026-01-01T00:00:00.000Z",
dataDir: DATA_DIR_A,
},
{
port: 4821,
pid: process.pid,
startedAt: "2026-01-02T00:00:00.000Z",
dataDir: DATA_DIR_A,
},
]);
assert.deepEqual(mod.resolveHookIngestPorts(), [4820]);
assert.deepEqual(mod.resolveAllDashboardPorts(), [4820, 4821]);
});
it("fans out when live servers use different data directories", () => {
const mod = freshModule(DATA_DIR_A);
writeDiscovery([
{
port: 4820,
pid: process.pid,
startedAt: "2026-01-01T00:00:00.000Z",
dataDir: DATA_DIR_A,
},
{
port: 4900,
pid: process.pid,
startedAt: "2026-01-02T00:00:00.000Z",
dataDir: DATA_DIR_B,
},
]);
assert.deepEqual(mod.resolveHookIngestPorts(), [4820, 4900]);
});
it("treats legacy entries without dataDir as unique per port", () => {
const mod = freshModule(DATA_DIR_A);
writeDiscovery([
{ port: 4820, pid: process.pid, startedAt: "2026-01-01T00:00:00.000Z" },
{ port: 4821, pid: process.pid, startedAt: "2026-01-02T00:00:00.000Z" },
]);
assert.deepEqual(mod.resolveHookIngestPorts(), [4820, 4821]);
});
it("records dataDir when writing server info", () => {
const mod = freshModule(DATA_DIR_A);
mod.writeServerInfo(4999);
const raw = JSON.parse(fs.readFileSync(mod.getServerInfoPath(), "utf8"));
const entry = raw.servers.find((s) => s.port === 4999);
assert.ok(entry);
assert.equal(mod.normalizeDataDir(entry.dataDir), mod.normalizeDataDir(DATA_DIR_A));
});
it("honors CLAUDE_DASHBOARD_PORT for hook ingest", () => {
const mod = freshModule(DATA_DIR_A);
process.env.CLAUDE_DASHBOARD_PORT = "7777";
writeDiscovery([
{
port: 4820,
pid: process.pid,
startedAt: "2026-01-01T00:00:00.000Z",
dataDir: DATA_DIR_A,
},
]);
assert.deepEqual(mod.resolveHookIngestPorts(), [7777]);
});
});
+395
View File
@@ -0,0 +1,395 @@
/**
* @file Tests for the watchdog's process-liveness reap. A session whose
* SessionEnd hook was lost (dashboard down when the user quit with Ctrl+C)
* must be completed once no running `claude` CLI process has the session's
* cwd — instead of sitting in Waiting until the 3 h stale sweep. Covers:
* - the claude-command matcher (`isClaudeCommand`),
* - probeLiveCwds shape + env escape hatch,
* - the reap itself: dead session → completed (agents completed, awaiting
* cleared, synthetic SessionEnd event), live session → untouched,
* - all fail-safe guards (probe unavailable, fresh activity, no cwd),
* - hook reactivation after a (hypothetical) false completion.
* The probe is stubbed by swapping `liveness.probeLiveCwds` on the shared
* module object — routes/hooks.js looks the function up at call time.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const STAMP = `liveness-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR = path.join(TMP, "data");
const TEST_DB = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = DATA_DIR;
// Keep the REAL probe inert for any watchdog interval tick that fires while
// this suite runs — stubbed probes below bypass this env check entirely.
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const liveness = require("../lib/session-liveness");
const hooksRouter = require("../routes/hooks");
const realProbe = liveness.probeLiveCwds;
const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
const PROJECTS = path.join(CLAUDE_HOME, "projects");
function writeTranscript(cwd, sessionId, lines) {
const p = path.join(PROJECTS, enc(cwd), `${sessionId}.jsonl`);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, lines.map((o) => JSON.stringify(o)).join("\n") + "\n");
return p;
}
/** Backdate a session row + its transcript so the idle gate passes. */
function backdate(sessionId, tpath, ageMs = 10 * 60 * 1000) {
const old = new Date(Date.now() - ageMs);
db.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?").run(old.toISOString(), sessionId);
if (tpath) fs.utimesSync(tpath, old, old);
}
function req(method, urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(b || "{}");
} catch {
parsed = b;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
r.on("error", reject);
if (payload) r.write(payload);
r.end();
});
}
/** Create a session + transcript via a real hook event, then backdate it. */
async function seedSession(sid, cwd, { old = true } = {}) {
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "hello" } },
]);
const res = await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(res.status, 200);
if (old) backdate(sid, tpath);
return tpath;
}
let server;
let BASE;
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
liveness.probeLiveCwds = realProbe;
if (server) server.close();
if (db) db.close();
try {
fs.rmSync(TMP, { recursive: true, force: true });
} catch {
/* ignore */
}
});
beforeEach(() => {
liveness.probeLiveCwds = realProbe;
});
describe("isClaudeCommand — claude CLI process matcher", () => {
const yes = [
"claude",
"claude --dangerously-skip-permissions",
"/usr/local/bin/claude -p hello",
"/Users/x/.local/bin/claude --resume abc",
"node /Users/x/.nvm/versions/node/v22.0.0/bin/claude",
"bun /opt/homebrew/bin/claude --model opus",
];
const no = [
"claude-mem --daemon",
"/Applications/Claude.app/Contents/MacOS/Claude",
"grep claude",
"node /app/server/index.js",
"node /Users/x/claude-dashboard/index.js",
"tail -f claude.log",
"",
];
for (const cmd of yes) {
it(`matches: ${cmd || "(empty)"}`, () => assert.equal(liveness.isClaudeCommand(cmd), true));
}
for (const cmd of no) {
it(`rejects: ${cmd || "(empty)"}`, () => assert.equal(liveness.isClaudeCommand(cmd), false));
}
});
describe("probeLiveCwds — probe availability", () => {
it("returns a well-formed result without throwing", () => {
delete process.env.DASHBOARD_LIVENESS_PROBE;
try {
const r = realProbe();
assert.equal(typeof r.available, "boolean");
assert.ok(r.cwds instanceof Set);
} finally {
process.env.DASHBOARD_LIVENESS_PROBE = "0";
}
});
it("is disabled by DASHBOARD_LIVENESS_PROBE=0", () => {
const r = realProbe(); // env is "0" for this whole suite
assert.equal(r.available, false);
assert.equal(r.cwds.size, 0);
});
it("is disabled inside a container (CCAM_FORCE_CONTAINER)", () => {
delete process.env.DASHBOARD_LIVENESS_PROBE;
process.env.CCAM_FORCE_CONTAINER = "1";
try {
assert.equal(realProbe().available, false);
} finally {
delete process.env.CCAM_FORCE_CONTAINER;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
}
});
});
describe("watchdog liveness reap", () => {
it("completes an idle active session whose cwd has no live claude process", async () => {
const sid = "dead0000-0000-0000-0000-000000000001";
const cwd = "/tmp/liveness-dead";
await seedSession(sid, cwd);
assert.equal(stmts.getSession.get(sid).status, "active");
assert.ok(stmts.getSession.get(sid).awaiting_input_since, "seeded as Waiting");
assert.equal(stmts.getSession.get(sid).awaiting_reason, "stop", "seeded via Stop hook");
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
const sess = stmts.getSession.get(sid);
assert.equal(sess.status, "completed");
assert.ok(sess.ended_at, "ended_at stamped");
assert.equal(sess.awaiting_input_since, null, "Waiting flag cleared");
assert.equal(sess.awaiting_reason, null, "awaiting_reason cleared alongside the flag");
const main = stmts.getAgent.get(`${sid}-main`);
assert.equal(main.status, "completed");
assert.equal(main.awaiting_input_since, null);
assert.equal(main.awaiting_reason, null);
const evt = db
.prepare(
"SELECT * FROM events WHERE session_id = ? AND event_type = 'SessionEnd' ORDER BY created_at DESC LIMIT 1"
)
.get(sid);
assert.ok(evt, "synthetic SessionEnd event recorded");
assert.match(evt.summary, /no running claude process/);
assert.equal(JSON.parse(evt.data).source, "liveness-probe");
});
it("leaves a session alone when a claude process runs in its cwd", async () => {
const sid = "live0000-0000-0000-0000-000000000002";
const cwd = "/tmp/liveness-alive";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set([path.resolve(cwd)]) });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("spares a household-hook session with a non-POSIX (Windows) cwd, even with zero live local processes", async () => {
// A session forwarded from a Windows machine reports cwd in that origin
// machine's own syntax. path.resolve() on this (POSIX) host doesn't
// recognize it as absolute, so it can never match anything the local
// /proc or lsof scan produces — that mismatch must not be treated as
// "process is dead".
const sid = "wind0000-0000-0000-0000-00000000000c";
const cwd = "D:\\Git\\ai-deck";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("still reaps a genuinely local (POSIX) cwd not in probe.cwds — regression guard", async () => {
const sid = "posx0000-0000-0000-0000-00000000000d";
const cwd = "/home/claude/projects/some-repo";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "completed");
});
it("does nothing when the probe is unavailable", async () => {
const sid = "unav0000-0000-0000-0000-000000000003";
const cwd = "/tmp/liveness-unavailable";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: false, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("spares sessions with recent activity (idle gate)", async () => {
const sid = "frsh0000-0000-0000-0000-000000000004";
const cwd = "/tmp/liveness-fresh";
await seedSession(sid, cwd, { old: false }); // updated_at + mtime are NOW
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("reaps a just-imported dead session: fresh updated_at, old transcript mtime", async () => {
// The boot shape: the startup sync imports a transcript last touched when
// the user quit (old mtime) but stamps updated_at = NOW. The gate must key
// on the transcript mtime, or the dead session sits in Waiting for a full
// extra LIVENESS_IDLE_SECONDS after every dashboard start.
const sid = "boot0000-0000-0000-0000-000000000009";
const cwd = "/tmp/liveness-boot-import";
const tpath = await seedSession(sid, cwd, { old: false });
const old = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(tpath, old, old); // transcript stopped moving 10 min ago
// updated_at stays fresh (the import just wrote the row).
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "completed");
});
it("boot pass (ignoreIdleGate) reaps a session quit seconds before launch", async () => {
// The reported flow: quit the session, IMMEDIATELY start the dashboard.
// Transcript mtime is only seconds old, so the gated reap would wait a
// full LIVENESS_IDLE_SECONDS — the boot passes must skip the gate and
// trust the probe alone.
const sid = "qikq0000-0000-0000-0000-00000000000a";
const cwd = "/tmp/liveness-quick-quit";
await seedSession(sid, cwd, { old: false }); // mtime + updated_at are NOW
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap({ ignoreIdleGate: true });
assert.equal(stmts.getSession.get(sid).status, "completed");
});
it("boot pass still spares a session whose claude process is alive", async () => {
const sid = "qikl0000-0000-0000-0000-00000000000b";
const cwd = "/tmp/liveness-quick-live";
await seedSession(sid, cwd, { old: false });
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set([path.resolve(cwd)]) });
hooksRouter.livenessReap({ ignoreIdleGate: true });
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("skips sessions without a cwd", async () => {
const sid = "nocw0000-0000-0000-0000-000000000005";
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid },
});
backdate(sid, null);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active");
});
it("spares a remote-source session with a POSIX cwd and no live local process", async () => {
// A Remote Data Source session (source = a remote id) legitimately reports a
// POSIX-absolute cwd on another machine (e.g. /home/ubuntu/matroid) that no
// local claude process owns. The posix-cwd guard can't catch it, so the
// source guard must: local process liveness says nothing about a remote box.
const sid = "rmt00000-0000-0000-0000-00000000000e";
const cwd = "/home/ubuntu/matroid";
const tpath = await seedSession(sid, cwd);
db.prepare("UPDATE sessions SET source = ? WHERE id = ?").run("src_remotebox", sid);
backdate(sid, tpath);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active", "remote session must not be reaped");
});
it("does not touch error sessions", async () => {
const sid = "errr0000-0000-0000-0000-000000000006";
const cwd = "/tmp/liveness-error";
const tpath = await seedSession(sid, cwd);
stmts.updateSession.run(null, "error", null, null, sid);
backdate(sid, tpath);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "error");
});
it("a reaped session reactivates on the next hook event (self-heal)", async () => {
const sid = "heal0000-0000-0000-0000-000000000007";
const cwd = "/tmp/liveness-heal";
const tpath = await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "completed");
const res = await req("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(res.status, 200);
assert.equal(stmts.getSession.get(sid).status, "active");
assert.equal(stmts.getAgent.get(`${sid}-main`).status, "working");
});
it("full watchdogCheck runs the reap end-to-end", async () => {
const sid = "wdog0000-0000-0000-0000-000000000008";
const cwd = "/tmp/liveness-watchdog";
await seedSession(sid, cwd);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.watchdogCheck();
assert.equal(stmts.getSession.get(sid).status, "completed");
});
});
@@ -0,0 +1,416 @@
/**
* @file Tests for human-readable session names and the transcript rename
* marker. Covers:
* - TranscriptCache surfacing the latest custom-title / ai-title.
* - The hook ingestor syncing sessions.name from the transcript title, with
* custom-title winning and ai-title only filling placeholder names.
* - GET /:id/transcript surfacing custom-title (/rename) as a synthetic
* `session_event` marker, deduped, with ai-title excluded.
* - GET /:id/transcript surfacing mid-turn queued user messages
* (attachment/queued_command) as user rows, with queue-operation
* bookkeeping and other attachment subtypes dropped.
* Uses Node's built-in test runner with temp CLAUDE_HOME / DASHBOARD_DATA_DIR.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const STAMP = `sess-name-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR = path.join(TMP, "data");
const TEST_DB = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = DATA_DIR;
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const TranscriptCache = require("../lib/transcript-cache");
const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
const PROJECTS = path.join(CLAUDE_HOME, "projects");
function jsonl(lines) {
return lines.map((o) => JSON.stringify(o)).join("\n") + "\n";
}
function writeTranscript(cwd, sessionId, lines) {
const p = path.join(PROJECTS, enc(cwd), `${sessionId}.jsonl`);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, jsonl(lines));
return p;
}
function req(method, urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(b || "{}");
} catch {
parsed = b;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
r.on("error", reject);
if (payload) r.write(payload);
r.end();
});
}
let server;
let BASE;
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.rmSync(TMP, { recursive: true, force: true });
} catch {
/* ignore */
}
});
describe("TranscriptCache — title extraction", () => {
it("returns the latest custom-title and ai-title (last value wins)", () => {
const cwd = "/tmp/cam-name-cache";
const sid = "cache-titles";
const p = writeTranscript(cwd, sid, [
{ type: "ai-title", aiTitle: "Auto one", sessionId: sid },
{ type: "user", message: { role: "user", content: "hi" } },
{ type: "ai-title", aiTitle: "Auto two", sessionId: sid },
{ type: "custom-title", customTitle: "my-feature", sessionId: sid },
]);
const r = new TranscriptCache().extract(p);
assert.equal(r.customTitle, "my-feature");
assert.equal(r.aiTitle, "Auto two");
});
it("returns a result for a transcript that has ONLY a title line", () => {
const cwd = "/tmp/cam-name-only";
const sid = "only-title";
const p = writeTranscript(cwd, sid, [
{ type: "custom-title", customTitle: "title-only", sessionId: sid },
]);
const r = new TranscriptCache().extract(p);
assert.ok(r, "result should not be null");
assert.equal(r.customTitle, "title-only");
});
});
describe("hook ingestor — sessions.name sync from transcript", () => {
it("sets the name to the custom-title on the next hook event", async () => {
const cwd = "/tmp/cam-name-custom";
const sid = "11111111-2222-3333-4444-555555555555";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "hello" } },
{ type: "custom-title", customTitle: "auth-refactor", sessionId: sid },
]);
const res = await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(res.status, 200);
const row = stmts.getSession.get(sid);
assert.equal(row.name, "auth-refactor");
});
it("fills a placeholder name with the ai-title when there is no custom-title", async () => {
const cwd = "/tmp/cam-name-ai";
const sid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "hello" } },
{ type: "ai-title", aiTitle: "Investigate flaky test", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
const row = stmts.getSession.get(sid);
assert.equal(row.name, "Investigate flaky test");
});
it("does not let an ai-title clobber a user-chosen name, but a custom-title does", async () => {
const cwd = "/tmp/cam-name-precedence";
const sid = "99999999-8888-7777-6666-555555555555";
// Seed a real, user-chosen name via the sessions API.
await req("POST", "/api/sessions", { id: sid, name: "keep-me", cwd });
// An ai-title must NOT overwrite the user-chosen name.
let tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "x" } },
{ type: "ai-title", aiTitle: "Auto generated", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "keep-me");
// But an explicit /rename (custom-title) always wins.
tpath = writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "x" } },
{ type: "ai-title", aiTitle: "Auto generated", sessionId: sid },
{ type: "custom-title", customTitle: "renamed-explicitly", sessionId: sid },
]);
await req("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: sid, cwd, transcript_path: tpath },
});
assert.equal(stmts.getSession.get(sid).name, "renamed-explicitly");
});
});
describe("GET /:id/transcript — rename markers", () => {
it("surfaces custom-title as a deduped session_event and excludes ai-title", async () => {
const cwd = "/tmp/cam-rename-marker";
const sid = "deadbeef-0000-1111-2222-333333333333";
writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "first" } },
{ type: "ai-title", aiTitle: "noise 1", sessionId: sid },
{ type: "custom-title", customTitle: "feature-x", sessionId: sid },
{ type: "ai-title", aiTitle: "noise 2", sessionId: sid },
// Duplicate custom-title with the SAME value — must be deduped away.
{ type: "custom-title", customTitle: "feature-x", sessionId: sid },
{
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "ok" }] },
},
{ type: "custom-title", customTitle: "feature-y", sessionId: sid },
]);
// Register the session so the endpoint doesn't 404.
await req("POST", "/api/sessions", { id: sid, cwd });
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
assert.equal(res.status, 200);
const events = res.body.messages.filter((m) => m.type === "session_event");
const titles = events.map((e) => e.title);
// feature-x once (dupe collapsed), then feature-y. No ai-title leaks in.
assert.deepEqual(titles, ["feature-x", "feature-y"]);
assert.ok(
events.every((e) => e.event_kind === "rename"),
"every marker is a rename"
);
assert.ok(
!res.body.messages.some((m) => m.type === "session_event" && /noise/.test(m.title || "")),
"ai-title values are never surfaced in the transcript stream"
);
});
});
describe("GET /:id/transcript — local slash-command output (system/local_command)", () => {
it("surfaces /color command + its stdout, skips empty + noise system lines", async () => {
const cwd = "/tmp/cam-local-cmd";
const sid = "c010rrrr-1111-2222-3333-444444444444";
writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "hi" } },
// /color, current Claude Code shape: command + output as system/local_command
{
type: "system",
subtype: "local_command",
content:
"<command-name>/color</command-name>\n <command-message>color</command-message>\n <command-args></command-args>",
sessionId: sid,
},
{ type: "agent-color", agentColor: "cyan", sessionId: sid },
{
type: "system",
subtype: "local_command",
content: "<local-command-stdout>Session color set to: cyan</local-command-stdout>",
sessionId: sid,
},
// /clear writes a content-less local_command line — must NOT become a row
{ type: "system", subtype: "local_command", content: "", sessionId: sid },
// unrelated system subtype — pure noise, must be dropped
{ type: "system", subtype: "turn_duration", durationMs: 1200, sessionId: sid },
]);
await req("POST", "/api/sessions", { id: sid, cwd });
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
assert.equal(res.status, 200);
const texts = res.body.messages.flatMap((m) =>
m.content.filter((c) => c.type === "text").map((c) => c.text)
);
assert.ok(
texts.some((tx) => tx.includes("<command-name>/color</command-name>")),
"the /color command invocation is surfaced"
);
assert.ok(
texts.some((tx) => tx.includes("Session color set to: cyan")),
"the /color stdout is surfaced"
);
// Empty local_command (/clear) and turn_duration noise produce no message.
assert.ok(
!texts.some((tx) => /turn_duration|durationMs/.test(tx)),
"non-local_command system subtypes are not surfaced"
);
// Exactly two surfaced rows from the system lines (command + stdout), plus
// the one real user message — the empty + noise lines add nothing.
assert.equal(res.body.messages.length, 3);
});
});
describe("GET /:id/transcript — mid-turn queued user messages (attachment/queued_command)", () => {
it("surfaces a queued_command attachment as a user message; drops queue-operation and other attachments", async () => {
const cwd = "/tmp/cam-queued-msg";
const sid = "aaaa1111-2222-3333-4444-555566667777";
writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "start the docs sweep" } },
{
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "working on it" }] },
},
// A message typed while Claude was mid-turn is journaled as queue-operation
// bookkeeping plus a queued_command attachment — there is NO user line.
{
type: "queue-operation",
operation: "enqueue",
content: "doc sweep too pls",
sessionId: sid,
},
{
type: "queue-operation",
operation: "remove",
content: "doc sweep too pls",
sessionId: sid,
},
{
type: "attachment",
attachment: {
type: "queued_command",
prompt: "doc sweep too pls",
commandMode: "prompt",
origin: { kind: "human" },
timestamp: "2026-07-16T02:40:45.596Z",
},
timestamp: "2026-07-16T02:40:45.596Z",
sessionId: sid,
},
// Non-queued_command attachments are harness noise — must NOT surface.
{
type: "attachment",
attachment: { type: "task_reminder", content: "reminder noise" },
sessionId: sid,
},
// A content-less queued_command must not become an empty row either.
{
type: "attachment",
attachment: { type: "queued_command", prompt: " ", origin: { kind: "human" } },
sessionId: sid,
},
]);
await req("POST", "/api/sessions", { id: sid, cwd });
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
assert.equal(res.status, 200);
const queued = res.body.messages.filter((m) =>
m.content.some((c) => c.type === "text" && c.text === "doc sweep too pls")
);
assert.equal(queued.length, 1, "the mid-turn message surfaces exactly once");
assert.equal(queued[0].type, "user");
assert.equal(
queued[0].sender,
"user",
"a human-typed queued message is attributed to the user"
);
assert.equal(queued[0].timestamp, "2026-07-16T02:40:45.596Z");
assert.ok(
!res.body.messages.some((m) =>
m.content.some((c) => c.type === "text" && /reminder noise/.test(c.text || ""))
),
"non-queued_command attachments stay hidden"
);
// start + working + the queued message; queue-operation lines, the noise
// attachment, and the blank prompt add nothing.
assert.equal(res.body.messages.length, 3);
});
it("attributes harness-injected queued_command lines (task-notifications) to system, not the user", async () => {
const cwd = "/tmp/cam-queued-sys";
const sid = "bbbb1111-2222-3333-4444-555566667777";
writeTranscript(cwd, sid, [
{ type: "user", message: { role: "user", content: "kick off the agents" } },
// Background-agent task-notification delivered through the SAME queue as
// typed messages — real shape: attachment has NO origin field at all.
{
type: "attachment",
attachment: {
type: "queued_command",
prompt:
"<task-notification>\n<task-id>a30201bfc90e18271</task-id>\n<status>completed</status>\n</task-notification>",
commandMode: "prompt",
timestamp: "2026-07-16T02:47:36.000Z",
},
timestamp: "2026-07-16T02:47:36.000Z",
sessionId: sid,
},
// Banner-prefixed variant must also be system.
{
type: "attachment",
attachment: {
type: "queued_command",
prompt: "[SYSTEM NOTIFICATION - NOT USER INPUT]\nautomated background-task event",
commandMode: "prompt",
},
sessionId: sid,
},
// Explicit non-human origin → system too.
{
type: "attachment",
attachment: {
type: "queued_command",
prompt: "sdk enqueued follow-up",
origin: { kind: "sdk" },
},
sessionId: sid,
},
// Missing origin but plain human-looking text stays user (older builds).
{
type: "attachment",
attachment: { type: "queued_command", prompt: "and update the docs" },
sessionId: sid,
},
]);
await req("POST", "/api/sessions", { id: sid, cwd });
const res = await req("GET", `/api/sessions/${sid}/transcript?limit=200`);
assert.equal(res.status, 200);
const bySnippet = (s) =>
res.body.messages.find((m) =>
m.content.some((c) => c.type === "text" && (c.text || "").includes(s))
);
assert.equal(bySnippet("<task-notification>").sender, "system");
assert.equal(bySnippet("[SYSTEM NOTIFICATION").sender, "system");
assert.equal(bySnippet("sdk enqueued follow-up").sender, "system");
assert.equal(bySnippet("and update the docs").sender, "user");
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* @file Regression test for issue #223 — the packaged desktop app froze on a
* large ~/.claude/projects history. The desktop shell hosts the Express server
* IN the Electron main process, so a session sweep that scans every file
* synchronously (statSync + a getSession query per file) with no yield freezes
* the whole window. `syncDefaultProjects` must yield to the event loop
* periodically — even on the all-unchanged, cold-cache fast path that never
* parses a transcript — so a multi-thousand-session history can't monopolize
* the loop. Under `npm start` the server is its own process, which is why the
* hang only reproduced in the packaged app.
*
* Runs in its own process (node --test isolates files), so pointing CLAUDE_HOME
* and DASHBOARD_DB_PATH at temp locations before requiring the modules gives a
* clean, isolated projects dir + database without touching the real ones.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-sync-yield-"));
process.env.CLAUDE_HOME = TMP_HOME;
process.env.DASHBOARD_DB_PATH = path.join(TMP_HOME, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP_HOME, "data");
const PROJECTS_DIR = path.join(TMP_HOME, "projects");
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
const dbModule = require("../db");
const { syncDefaultProjects } = require("../../scripts/import-history");
// Enough sessions that the sweep crosses several yield boundaries
// (SWEEP_YIELD_EVERY_FILES = 100), so a cooperative sweep interleaves multiple
// event-loop ticks while a blocking one interleaves zero.
const SESSION_COUNT = 300;
function sessionLines(sessionId) {
return [
{
type: "user",
cwd: "/w",
sessionId,
timestamp: "2026-04-18T12:00:00.000Z",
message: { content: "hi" },
},
{
type: "assistant",
cwd: "/w",
sessionId,
timestamp: "2026-04-18T12:00:00.000Z",
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: 10, output_tokens: 5 },
},
},
];
}
before(() => {
const projDir = path.join(PROJECTS_DIR, "-w");
fs.mkdirSync(projDir, { recursive: true });
for (let i = 0; i < SESSION_COUNT; i++) {
// Valid v4 UUID shape, unique per index.
const suffix = i.toString(16).padStart(12, "0");
const id = `00000000-0000-4000-8000-${suffix}`;
fs.writeFileSync(
path.join(projDir, `${id}.jsonl`),
sessionLines(id)
.map((o) => JSON.stringify(o))
.join("\n") + "\n"
);
}
});
after(() => {
if (dbModule.db) dbModule.db.close();
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
describe("syncDefaultProjects cooperative yielding (#223)", () => {
it("imports the full history on the first sweep", async () => {
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache: new Map() });
assert.equal(
changed.length,
SESSION_COUNT,
"every session is imported on the cold first sweep"
);
});
it("yields to the event loop during an all-unchanged cold-cache sweep", async () => {
// Count event-loop ticks that fire DURING the sweep. A blocking sweep runs
// the whole scan synchronously (no await on the unchanged fast path), so a
// setImmediate chain scheduled alongside it gets zero ticks until it
// finishes. A cooperative sweep yields every SWEEP_YIELD_EVERY_FILES files,
// letting the chain advance mid-sweep.
let ticks = 0;
let active = true;
const pump = () => {
if (active) {
ticks += 1;
setImmediate(pump);
}
};
setImmediate(pump);
// Fresh (cold) cache but the rows already exist and the files are unchanged,
// so every file takes the fast path — the exact restart/poll scenario that
// froze the app. Nothing is reported as changed.
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache: new Map() });
active = false;
assert.equal(changed.length, 0, "an unchanged sweep still reports no work");
assert.ok(
ticks >= 2,
`sweep must yield to the event loop on the fast path (observed ${ticks} ticks across ${SESSION_COUNT} files)`
);
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* @file Tests for syncDefaultProjects — the incremental, mtime-fingerprinted
* sync of ~/.claude/projects that backs the background session-sync poll
* (server/index.js startSessionSync). Verifies that a project added after the
* one-time backfill is discovered, that an unchanged sweep does no work, and
* that a grown session is reported as an update (not a new session).
*
* Runs in its own process (node --test isolates files), so pointing CLAUDE_HOME
* and DASHBOARD_DB_PATH at temp locations before requiring the modules gives a
* clean, isolated projects dir + database without touching the real ones.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-sync-home-"));
process.env.CLAUDE_HOME = TMP_HOME;
process.env.DASHBOARD_DB_PATH = path.join(TMP_HOME, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP_HOME, "data");
const PROJECTS_DIR = path.join(TMP_HOME, "projects");
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
const dbModule = require("../db");
const { syncDefaultProjects } = require("../../scripts/import-history");
const SESSION_NEW = "11111111-aaaa-4aaa-8aaa-111111111111";
function fixtureLines(sessionId, cwd, extra = []) {
return [
{
type: "user",
cwd,
sessionId,
timestamp: "2026-04-18T12:00:00.000Z",
message: { content: "hi" },
},
{
type: "assistant",
cwd,
sessionId,
timestamp: "2026-04-18T12:00:00.000Z",
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: 10, output_tokens: 5 },
},
},
...extra,
];
}
function writeSession(projName, sessionId, lines) {
const projDir = path.join(PROJECTS_DIR, projName);
fs.mkdirSync(projDir, { recursive: true });
const file = path.join(projDir, `${sessionId}.jsonl`);
fs.writeFileSync(file, lines.map((o) => JSON.stringify(o)).join("\n") + "\n");
return file;
}
after(() => {
if (dbModule.db) dbModule.db.close();
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
describe("syncDefaultProjects", () => {
const mtimeCache = new Map();
it("discovers a newly added project and reports it as a new session", async () => {
writeSession("-work", SESSION_NEW, fixtureLines(SESSION_NEW, "/work"));
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache });
const hit = changed.find((c) => c.sessionId === SESSION_NEW);
assert.ok(hit, "the new session should be reported");
assert.equal(hit.isNew, true, "a session not yet in the DB is new");
assert.ok(dbModule.stmts.getSession.get(SESSION_NEW), "session should be imported into the DB");
});
it("does no work on a second sweep when nothing changed", async () => {
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache });
assert.equal(changed.length, 0, "an unchanged sweep reports no sessions");
});
it("reports a grown session as an update, not a new session", async () => {
const file = writeSession(
"-work",
SESSION_NEW,
fixtureLines(SESSION_NEW, "/work", [
{
type: "assistant",
cwd: "/work",
sessionId: SESSION_NEW,
timestamp: "2026-04-18T12:05:00.000Z",
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "more" }],
usage: { input_tokens: 7, output_tokens: 3 },
},
},
])
);
// Force a clearly-later mtime so the sweep treats the file as changed.
const future = Date.now() / 1000 + 60;
fs.utimesSync(file, future, future);
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache });
const hit = changed.find((c) => c.sessionId === SESSION_NEW);
assert.ok(hit, "the grown session should be reported");
assert.equal(hit.isNew, false, "an already-imported session counts as an update");
});
it("never throws when the projects dir is empty of sessions", async () => {
const emptyCache = new Map();
const emptyProj = path.join(PROJECTS_DIR, "-empty");
fs.mkdirSync(emptyProj, { recursive: true });
const { changed } = await syncDefaultProjects(dbModule, { mtimeCache: emptyCache });
// -empty contributes nothing; the pre-existing -work session is brand new to
// this fresh cache, so it is reported once — but the empty dir must not error.
assert.ok(Array.isArray(changed));
});
it("skips re-parsing an already-imported, unchanged session even on a cold cache", async () => {
// Models the immediate sweep after every server restart: mtimeCache is
// empty, but the DB already holds these sessions. An unchanged file must NOT
// be re-parsed/re-reported (the cold-restart fast path), so a large history
// doesn't re-parse every transcript on boot.
const SESSION_STABLE = "22222222-bbbb-4bbb-8bbb-222222222222";
writeSession("-stable", SESSION_STABLE, fixtureLines(SESSION_STABLE, "/stable"));
// First sweep (cold cache) imports it.
const first = await syncDefaultProjects(dbModule, { mtimeCache: new Map() });
assert.ok(
first.changed.find((c) => c.sessionId === SESSION_STABLE && c.isNew),
"first sweep imports the new session"
);
// Second sweep with a FRESH (cold) cache: the file is unchanged and the row
// exists, so the gate skips it — nothing reported for SESSION_STABLE.
const second = await syncDefaultProjects(dbModule, { mtimeCache: new Map() });
assert.equal(
second.changed.find((c) => c.sessionId === SESSION_STABLE),
undefined,
"an unchanged, already-imported session is skipped on a cold cache"
);
});
});
@@ -0,0 +1,114 @@
/**
* @file Verifies the idempotent ALTER TABLE migration adds a transcript_path
* column to sessions and that a fresh db.js load on an existing DB does not
* throw or duplicate the column.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
let TEST_DB;
before(() => {
TEST_DB = path.join(os.tmpdir(), `dashboard-tp-migration-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
});
after(() => {
try {
fs.unlinkSync(TEST_DB);
} catch {}
try {
fs.unlinkSync(TEST_DB + "-wal");
} catch {}
try {
fs.unlinkSync(TEST_DB + "-shm");
} catch {}
});
describe("sessions.transcript_path migration", () => {
it("adds transcript_path column on first load", () => {
delete require.cache[require.resolve("../db")];
const { db } = require("../db");
const cols = db.prepare("PRAGMA table_info(sessions)").all();
const names = cols.map((c) => c.name);
assert.ok(
names.includes("transcript_path"),
`expected transcript_path; got: ${names.join(",")}`
);
});
it("is idempotent — loading db.js a second time does not throw", () => {
delete require.cache[require.resolve("../db")];
assert.doesNotThrow(() => require("../db"));
});
it("transcript_path is nullable and accepts an UPDATE", () => {
const { db, stmts } = require("../db");
stmts.insertSession.run("s-tp-1", "name", "active", "/tmp/proj", "claude", null);
db.prepare("UPDATE sessions SET transcript_path = ? WHERE id = ?").run(
"/tmp/foo.jsonl",
"s-tp-1"
);
const row = db.prepare("SELECT transcript_path FROM sessions WHERE id = ?").get("s-tp-1");
assert.equal(row.transcript_path, "/tmp/foo.jsonl");
});
});
describe("hooks ingestion populates sessions.transcript_path", () => {
it("sets transcript_path on first event that carries it", async () => {
delete require.cache[require.resolve("../db")];
const { db, stmts } = require("../db");
// Pre-create a session without transcript_path (simulate legacy state)
stmts.insertSession.run("s-hook-1", "n", "active", "/tmp/proj", "claude", null);
let row = db.prepare("SELECT transcript_path FROM sessions WHERE id = ?").get("s-hook-1");
assert.equal(row.transcript_path, null);
// Spin up app and POST a hook event with transcript_path
delete require.cache[require.resolve("../index")];
const { createApp, startServer } = require("../index");
const app = createApp();
const server = await startServer(app, 0);
const port = server.address().port;
const payload = JSON.stringify({
hook_type: "PostToolUse",
data: {
session_id: "s-hook-1",
transcript_path: "/tmp/somewhere/session.jsonl",
cwd: "/tmp/proj",
},
});
await new Promise((resolve, reject) => {
const http = require("http");
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/hooks/event",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
res.resume();
res.once("end", resolve);
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
server.close();
row = db.prepare("SELECT transcript_path FROM sessions WHERE id = ?").get("s-hook-1");
assert.equal(row.transcript_path, "/tmp/somewhere/session.jsonl");
});
});
+297
View File
@@ -0,0 +1,297 @@
/**
* @file Tests the pure pipeline-rule matcher, including defensive input
* flattening, invalid-template handling, and later-stage precedence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const test = require("node:test");
const assert = require("node:assert/strict");
const { compileRules, detect, flattenInput } = require("../lib/stage-detect");
const { getPipeline } = require("../lib/pipelines");
const pipeline = {
nodes: [
{ id: "plan", detect: [{ tool: "Skill", match: "brainstorming" }] },
{ id: "implement", detect: [{ tool: "Edit" }] },
{ id: "tests", detect: [{ tool: "Bash", match: "npm run test:server" }] },
{ id: "ship", detect: [{ tool: "Bash", match: "git push" }] },
],
};
test("detects declared rules and reports a readable signal", () => {
assert.deepEqual(
detect(pipeline, { tool_name: "Bash", tool_input: { command: "npm run test:server" } }),
{
nodeId: "tests",
signal: "`npm run test:server`",
}
);
assert.deepEqual(
detect(pipeline, { tool_name: "Edit", tool_input: { file_path: "server/lib/app.js" } }),
{
nodeId: "implement",
signal: "`server/lib/app.js`",
}
);
assert.deepEqual(
detect(pipeline, { tool_name: "Skill", tool_input: { skill: "brainstorming" } }),
{
nodeId: "plan",
signal: "`brainstorming`",
}
);
assert.deepEqual(
detect(pipeline, { tool_name: "Bash", tool_input: { command: "git push origin main" } }),
{
nodeId: "ship",
signal: "`git push origin main`",
}
);
});
test("returns null for unmatched or malformed events", () => {
assert.equal(
detect(pipeline, { tool_name: "Read", tool_input: { file_path: "README.md" } }),
null
);
assert.equal(detect(pipeline, {}), null);
assert.equal(detect(null, { tool_name: "Edit", tool_input: {} }), null);
});
test("skips invalid regular expressions while retaining valid rules", () => {
const malformed = {
nodes: [
{ id: "broken", detect: [{ tool: "Bash", match: "[" }] },
{ id: "tests", detect: [{ tool: "Bash", match: "npm run test:server" }] },
],
};
assert.deepEqual(compileRules(malformed), [
{ nodeId: "broken", rules: [] },
{ nodeId: "tests", rules: [{ tool: "Bash", regex: /npm run test:server/ }] },
]);
assert.deepEqual(
detect(malformed, { tool_name: "Bash", tool_input: { command: "npm run test:server" } }),
{
nodeId: "tests",
signal: "`npm run test:server`",
}
);
});
test("flattenInput handles awkward tool-input shapes without traversing deeply", () => {
assert.equal(flattenInput(null), "");
assert.equal(flattenInput("git push"), "git push");
assert.equal(flattenInput(["git", "push", { command: "origin main" }]), "git push origin main");
assert.equal(
flattenInput({
command: "npm test",
file_path: "server/app.js",
count: 1,
nested: { prompt: "ignored" },
}),
"npm test server/app.js"
);
assert.equal(flattenInput({ nested: { command: "never recurse" } }), "");
});
test("caps a long signal so it never inflates a DB column or a title attribute", () => {
const longCommand = `git push ${"x".repeat(4990)}`;
const result = detect(pipeline, { tool_name: "Bash", tool_input: { command: longCommand } });
assert.ok(result.signal.length < longCommand.length);
assert.ok(result.signal.startsWith("`git push xxxx"));
assert.ok(result.signal.endsWith("…`"));
});
test("flattenInput keeps only fields that identify what a tool did, dropping Edit's code payload", () => {
assert.equal(
flattenInput({
file_path: "server/lib/app.js",
old_string: "function old() { return 1; }",
new_string: "function updated() { return 2; }",
}),
"server/lib/app.js"
);
const signal = detect(pipeline, {
tool_name: "Edit",
tool_input: {
file_path: "server/lib/app.js",
old_string: "function old() { return 1; }",
new_string: "function updated() { return 2; }",
},
}).signal;
assert.ok(signal.includes("server/lib/app.js"));
assert.ok(!signal.includes("function old"));
assert.ok(!signal.includes("function updated"));
});
test("compileRules stays total on a null pipeline, non-array nodes, and a non-array detect", () => {
assert.deepEqual(compileRules(null), []);
assert.deepEqual(compileRules({ nodes: "not-an-array" }), []);
assert.deepEqual(compileRules({ nodes: [{ id: "weird", detect: "not-an-array" }] }), [
{ nodeId: "weird", rules: [] },
]);
});
test("detects stage through the real default pipeline template, end-to-end via getPipeline", () => {
const pipeline = getPipeline("default");
assert.equal(
detect(pipeline, { tool_name: "Bash", tool_input: { command: "npm run test:server" } }).nodeId,
"tests"
);
assert.equal(
detect(pipeline, { tool_name: "Edit", tool_input: { file_path: "server/lib/app.js" } }).nodeId,
"implement"
);
});
// Every rule default.json ships, asserted through the REAL loader. A rule
// pinned only against a hand-written fixture pipeline can ship inert — that is
// how both the dropped-`detect` loader bug and the shadowed `plan` Write rule
// got through review.
test("every shipped default.json rule fires end-to-end via getPipeline", () => {
const pipeline = getPipeline("default");
const cases = [
["plan", "Skill", { skill: "brainstorming" }],
["plan", "Skill", { skill: "writing-plans" }],
["implement", "Edit", { file_path: "server/lib/app.js" }],
["implement", "Write", { file_path: "server/lib/app.js" }],
["tests", "Bash", { command: "npm run test:server" }],
["tests", "Bash", { command: "pytest -q" }],
["review", "Skill", { skill: "requesting-code-review" }],
["review", "Bash", { command: "gh pr diff 12" }],
["ship", "Bash", { command: "git push origin HEAD" }],
["ship", "Bash", { command: "gh pr create --fill" }],
];
for (const [nodeId, tool_name, tool_input] of cases) {
const got = detect(pipeline, { tool_name, tool_input });
assert.equal(got && got.nodeId, nodeId, `${tool_name} ${JSON.stringify(tool_input)}`);
}
});
test("a plan document written under docs/ reports plan, code written elsewhere reports implement", () => {
const pipeline = getPipeline("default");
// `implement`'s Write rule is a LATER node than `plan`'s and detect() takes
// the last match, so it must exclude docs/ paths or plan's rule can never fire.
assert.equal(
detect(pipeline, {
tool_name: "Write",
tool_input: { file_path: "docs/superpowers/plans/foo.md" },
}).nodeId,
"plan"
);
assert.equal(
detect(pipeline, {
tool_name: "Write",
tool_input: { file_path: "/home/me/repo/docs/superpowers/plans/foo.md" },
}).nodeId,
"plan"
);
assert.equal(
detect(pipeline, { tool_name: "Write", tool_input: { file_path: "server/lib/x.js" } }).nodeId,
"implement"
);
// A docs/ write that is not a plan document matches neither rule.
assert.equal(
detect(pipeline, { tool_name: "Write", tool_input: { file_path: "docs/README.md" } }),
null
);
});
test("loadAll preserves a node's detect rules, and tolerates a non-array detect without throwing", () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pipelines-detect-"));
fs.writeFileSync(
path.join(dir, "custom.json"),
JSON.stringify({
id: "custom-detect-test",
nodes: [
{ id: "a", detect: [{ tool: "Bash", match: "foo" }] },
{ id: "b", detect: "not-an-array" },
],
})
);
const { getPipeline, reload } = require("../lib/pipelines");
const prevDir = process.env.DASHBOARD_PIPELINES_DIR;
process.env.DASHBOARD_PIPELINES_DIR = dir;
reload();
try {
const p = getPipeline("custom-detect-test");
assert.deepEqual(p.nodes[0].detect, [{ tool: "Bash", match: "foo" }]);
assert.deepEqual(p.nodes[1].detect, []);
} finally {
if (prevDir === undefined) delete process.env.DASHBOARD_PIPELINES_DIR;
else process.env.DASHBOARD_PIPELINES_DIR = prevDir;
reload();
}
});
test("the last matching node wins", () => {
const overlapping = {
nodes: [
{ id: "implement", detect: [{ tool: "Bash", match: "git" }] },
{ id: "ship", detect: [{ tool: "Bash", match: "git push" }] },
],
};
assert.deepEqual(
detect(overlapping, { tool_name: "Bash", tool_input: { command: "git push" } }),
{
nodeId: "ship",
signal: "`git push`",
}
);
});
test.describe("signal reports the matched span", () => {
const pipeline = getPipeline("default");
test.it("keeps the matched command and drops a long unrelated prefix", () => {
const long = "cd /home/very/long/path/that/goes/on/forever && npm run test:server 2>&1";
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: long } });
assert.equal(r.nodeId, "tests");
assert.ok(r.signal.includes("npm run test:server"), r.signal);
assert.ok(!r.signal.includes("/home/very/long/path/that/goes/on/forever"), r.signal);
});
test.it("marks both ends it trimmed with an ellipsis", () => {
const long = `${"a".repeat(80)} npm test ${"b".repeat(80)}`;
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: long } });
assert.ok(r.signal.startsWith("`…"), r.signal);
assert.ok(r.signal.endsWith("…`"), r.signal);
});
test.it("keeps a match at the very start without a leading ellipsis", () => {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: "npm test" } });
assert.equal(r.signal, "`npm test`");
});
test.it("falls back to the flattened input for a rule with no match pattern", () => {
const noMatch = {
id: "custom",
nodes: [{ id: "implement", label: "implement", aliases: [], detect: [{ tool: "Edit" }] }],
};
const r = detect(noMatch, { tool_name: "Edit", tool_input: { file_path: "server/lib/x.js" } });
assert.equal(r.signal, "`server/lib/x.js`");
});
test.it("still caps the fallback when a no-match rule sees a huge input", () => {
const noMatch = {
id: "custom",
nodes: [{ id: "implement", label: "implement", aliases: [], detect: [{ tool: "Edit" }] }],
};
const r = detect(noMatch, { tool_name: "Edit", tool_input: { file_path: "x".repeat(400) } });
// 120 chars + the ellipsis + the two wrapping backticks, minus nothing.
assert.equal(r.signal.length, 123);
assert.ok(r.signal.endsWith("\u2026`"), r.signal);
});
test.it("still returns null for a tool no rule mentions", () => {
assert.equal(detect(pipeline, { tool_name: "Read", tool_input: { file_path: "a.js" } }), null);
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* @file stream-json-parser.test.js
* @description Unit tests for the newline-delimited JSON line buffer used to
* parse `claude --output-format stream-json` output. Verifies chunked input,
* partial lines spanning chunks, malformed lines, empty input, multiple
* objects per chunk, and flush semantics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { createLineParser } = require("../lib/stream-json-parser");
function collect() {
const objects = [];
const errors = [];
const parser = createLineParser(
(obj) => objects.push(obj),
(err, raw) => errors.push({ message: err.message, raw })
);
return { parser, objects, errors };
}
describe("stream-json-parser", () => {
it("parses a single complete line", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"system","subtype":"init"}\n');
assert.equal(errors.length, 0);
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "system");
});
it("parses multiple lines in one chunk", () => {
const { parser, objects } = collect();
parser.push('{"type":"a"}\n{"type":"b"}\n{"type":"c"}\n');
assert.deepEqual(
objects.map((o) => o.type),
["a", "b", "c"]
);
});
it("reassembles a JSON object split across two chunks", () => {
const { parser, objects } = collect();
parser.push('{"type":"split","val":');
parser.push('"hello"}\n');
assert.equal(objects.length, 1);
assert.equal(objects[0].val, "hello");
});
it("reassembles a JSON object split across many small chunks", () => {
const { parser, objects } = collect();
const full = '{"type":"chunky","payload":{"deep":{"nested":[1,2,3]}}}\n';
for (const ch of full) parser.push(ch);
assert.equal(objects.length, 1);
assert.deepEqual(objects[0].payload.deep.nested, [1, 2, 3]);
});
it("ignores blank lines between objects", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"a"}\n\n\n{"type":"b"}\n');
assert.equal(objects.length, 2);
assert.equal(errors.length, 0);
});
it("reports malformed JSON via onError without throwing", () => {
const { parser, objects, errors } = collect();
parser.push("not valid json\n");
parser.push('{"type":"ok"}\n');
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "ok");
assert.equal(errors.length, 1);
assert.match(errors[0].raw, /not valid json/);
});
it("does not emit a partial line until newline arrives", () => {
const { parser, objects } = collect();
parser.push('{"type":"unfinished"');
assert.equal(objects.length, 0);
parser.push("}\n");
assert.equal(objects.length, 1);
});
it("flush() emits trailing line without newline", () => {
const { parser, objects } = collect();
parser.push('{"type":"trailing"}');
assert.equal(objects.length, 0);
parser.flush();
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "trailing");
});
it("flush() on empty buffer is a no-op", () => {
const { parser, objects, errors } = collect();
parser.flush();
assert.equal(objects.length, 0);
assert.equal(errors.length, 0);
});
it("flush() reports malformed trailing line via onError", () => {
const { parser, objects, errors } = collect();
parser.push("garbage{not-json");
parser.flush();
assert.equal(objects.length, 0);
assert.equal(errors.length, 1);
});
it("works without onError callback when input is malformed", () => {
let count = 0;
const parser = createLineParser((_o) => count++);
// No throw expected.
parser.push("garbage\n");
parser.push('{"type":"ok"}\n');
assert.equal(count, 1);
});
it("handles CRLF line endings cleanly (\\r is trimmed before parse)", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"crlf"}\r\n');
// Note: parser only splits on \n; the \r at end of line stays in the
// line. JSON.parse tolerates trailing whitespace including \r.
assert.equal(errors.length, 0);
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "crlf");
});
it("handles a stream-json envelope with stream_event sub-event shape", () => {
const { parser, objects } = collect();
const env = JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
},
session_id: "sess",
});
parser.push(env + "\n");
assert.equal(objects.length, 1);
assert.equal(objects[0].event.delta.text, "Hello");
});
});
@@ -0,0 +1,754 @@
/**
* @file Tests for subagent tool-event attribution.
*
* Subagent tool calls (Read, Bash, Edit, etc.) never fire hooks on the
* parent session — they only show up in the subagent's own JSONL file.
* Without dedicated extraction, every subagent ends up with at most a
* single spawn event, leaving 561/561 historical subagents with 05
* events instead of the dozens-to-hundreds they actually performed.
*
* This suite verifies that:
* 1. parseSubagentFile pairs tool_use blocks with their tool_result
* counterparts and surfaces them as `toolEvents`.
* 2. importSubagentFromJsonl emits PreToolUse + PostToolUse events
* under the subagent's own `agent_id`, so the UI attributes them
* to the subagent rather than the main agent.
* 3. Re-running the import is idempotent — no duplicate event rows.
* 4. When a live subagent (created via PreToolUse "Agent" hook) matches
* the JSONL by type + start time, events attach to the live row
* instead of creating a duplicate JSONL-keyed row.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `dashboard-subagent-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const importHistory = require("../../scripts/import-history");
const { calculateCost } = require("../routes/pricing");
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// ── Fixture helpers ──────────────────────────────────────────────────
function writeSubagentJsonl(filePath, lines) {
fs.writeFileSync(filePath, lines.map((o) => JSON.stringify(o)).join("\n"));
}
/**
* Builds a minimal subagent JSONL with two tool calls — one Read with a
* paired tool_result, and one Bash with a paired error tool_result.
*/
function buildSubagentLines(agentType = "coder") {
return [
{
type: "user",
timestamp: "2026-04-28T10:00:00.000Z",
message: { content: [{ type: "text", text: "Investigate the bug" }] },
},
{
type: "assistant",
timestamp: "2026-04-28T10:00:01.000Z",
message: {
model: "claude-opus-4-7",
content: [
{
type: "tool_use",
id: "toolu_read_001",
name: "Read",
input: { file_path: "/tmp/foo.py" },
},
],
usage: {
input_tokens: 50,
output_tokens: 20,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
{
type: "user",
timestamp: "2026-04-28T10:00:02.000Z",
message: {
content: [
{
type: "tool_result",
tool_use_id: "toolu_read_001",
content: "def main():\n pass\n",
},
],
},
},
{
type: "assistant",
timestamp: "2026-04-28T10:00:03.000Z",
message: {
model: "claude-opus-4-7",
content: [
{
type: "tool_use",
id: "toolu_bash_002",
name: "Bash",
input: { command: "ls /tmp" },
},
],
},
},
{
type: "user",
timestamp: "2026-04-28T10:00:04.000Z",
message: {
content: [
{
type: "tool_result",
tool_use_id: "toolu_bash_002",
content: "ls: cannot access /tmp: not allowed",
is_error: true,
},
],
},
},
{
// Meta: keeps file timestamps coherent
type: "user",
timestamp: "2026-04-28T10:00:05.000Z",
message: { content: [{ type: "text", text: "done" }] },
},
].map((line, i) => {
// Inject agentType into one entry as a hint, mirroring real CC output
if (i === 0) line.agentType = agentType;
return line;
});
}
function writeMetaJson(filePath, agentType) {
fs.writeFileSync(filePath, JSON.stringify({ agentType }));
}
/**
* Minimal subagent JSONL on a specific model carrying one usage record, so
* parseSubagentFile yields a single token bucket keyed by that model.
*/
function buildModelSubLines(agentType, model, usage, startedAt = "2026-05-01T10:00:00.000Z") {
const t0 = startedAt;
return [
{
type: "user",
timestamp: t0,
agentType,
message: { content: [{ type: "text", text: "go" }] },
},
{
type: "assistant",
timestamp: "2026-05-01T10:00:01.000Z",
message: {
model,
content: [{ type: "tool_use", id: `toolu_${agentType}_1`, name: "Read", input: {} }],
usage: {
input_tokens: usage.input || 0,
output_tokens: usage.output || 0,
cache_read_input_tokens: usage.cacheRead || 0,
cache_creation_input_tokens: usage.cacheWrite || 0,
},
},
},
{
type: "user",
timestamp: "2026-05-01T10:00:02.000Z",
message: {
content: [{ type: "tool_result", tool_use_id: `toolu_${agentType}_1`, content: "ok" }],
},
},
];
}
// Lay out <base>/<sessionId>/subagents/agent-*.jsonl and return the matching
// transcriptPath (<base>/<sessionId>.jsonl) that scanAndImportSubagents expects.
function buildSubagentDir(sessionId, files) {
const base = fs.mkdtempSync(path.join(os.tmpdir(), `sa-scan-${process.pid}-`));
const subDir = path.join(base, sessionId, "subagents");
fs.mkdirSync(subDir, { recursive: true });
for (const f of files) {
writeSubagentJsonl(path.join(subDir, `agent-${f.id}.jsonl`), f.lines);
// Companion meta.json so parseSubagentFile resolves agentType (the live-match key).
if (f.agentType) {
fs.writeFileSync(
path.join(subDir, `agent-${f.id}.meta.json`),
JSON.stringify({ agentType: f.agentType })
);
}
}
return { transcriptPath: path.join(base, `${sessionId}.jsonl`), base };
}
// ── Tests ────────────────────────────────────────────────────────────
describe("parseSubagentFile — tool event extraction", () => {
it("pairs tool_use with tool_result and returns ordered toolEvents", async () => {
const tmpFile = path.join(os.tmpdir(), `agent-${Date.now()}-${process.pid}.jsonl`);
writeSubagentJsonl(tmpFile, buildSubagentLines("coder"));
writeMetaJson(tmpFile.replace(/\.jsonl$/, ".meta.json"), "coder");
try {
const data = await importHistory.parseSubagentFile(tmpFile);
assert.ok(data, "subagent data should parse");
assert.equal(data.agentType, "coder");
assert.ok(Array.isArray(data.toolEvents));
assert.equal(data.toolEvents.length, 2);
const [readEv, bashEv] = data.toolEvents;
assert.equal(readEv.tool_use_id, "toolu_read_001");
assert.equal(readEv.tool_name, "Read");
assert.deepEqual(readEv.tool_input, { file_path: "/tmp/foo.py" });
assert.equal(readEv.is_error, false);
assert.equal(typeof readEv.pre_timestamp, "string");
assert.equal(typeof readEv.post_timestamp, "string");
assert.ok(readEv.tool_response);
assert.equal(bashEv.tool_use_id, "toolu_bash_002");
assert.equal(bashEv.is_error, true);
} finally {
fs.unlinkSync(tmpFile);
try {
fs.unlinkSync(tmpFile.replace(/\.jsonl$/, ".meta.json"));
} catch {
/* ignore */
}
}
});
it("emits a tool_use even when no matching tool_result exists yet (live tail)", async () => {
const tmpFile = path.join(os.tmpdir(), `agent-tail-${Date.now()}-${process.pid}.jsonl`);
writeSubagentJsonl(tmpFile, [
{
type: "assistant",
timestamp: "2026-04-28T10:00:01.000Z",
message: {
model: "claude-opus-4-7",
content: [{ type: "tool_use", id: "toolu_pending", name: "Read", input: {} }],
},
},
]);
try {
const data = await importHistory.parseSubagentFile(tmpFile);
assert.equal(data.toolEvents.length, 1);
const ev = data.toolEvents[0];
assert.equal(ev.tool_use_id, "toolu_pending");
assert.equal(ev.post_timestamp, null);
assert.equal(ev.tool_response, null);
} finally {
fs.unlinkSync(tmpFile);
}
});
});
describe("importSubagentFromJsonl — event attribution", () => {
const sessionId = "test-sess-attribution";
const mainAgentId = `${sessionId}-main`;
before(() => {
// Seed session + main agent so importSubagentFromJsonl has parents to point at.
stmts.insertSession.run(sessionId, "Test Session", "active", "/tmp", null, null);
stmts.insertAgent.run(
mainAgentId,
sessionId,
"Main Agent",
"main",
null,
"waiting",
null,
null,
null
);
});
it("creates one subagent row and per-call PreToolUse + PostToolUse events", async () => {
const tmpFile = path.join(os.tmpdir(), `agent-attr-${Date.now()}-${process.pid}.jsonl`);
writeSubagentJsonl(tmpFile, buildSubagentLines("coder"));
writeMetaJson(tmpFile.replace(/\.jsonl$/, ".meta.json"), "coder");
try {
const data = await importHistory.parseSubagentFile(tmpFile);
const created = importHistory.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, data);
assert.ok(created > 0, "should create at least the agent + spawn + 4 events");
const subId = `${sessionId}-jsonl-${data.agentId}`;
const subAgent = stmts.getAgent.get(subId);
assert.ok(subAgent, "JSONL-keyed subagent row should exist");
assert.equal(subAgent.parent_agent_id, mainAgentId);
const toolEvents = db
.prepare(
"SELECT event_type, tool_name FROM events WHERE agent_id = ? AND event_type IN ('PreToolUse', 'PostToolUse') ORDER BY id ASC"
)
.all(subId);
assert.equal(toolEvents.length, 4, "expected 2 Pre + 2 Post events under subagent's id");
assert.deepEqual(
toolEvents.map((e) => `${e.event_type}:${e.tool_name}`),
["PreToolUse:Read", "PostToolUse:Read", "PreToolUse:Bash", "PostToolUse:Bash"]
);
// Spawn marker lives under the main agent so the parent chain shows
// "Subagent spawned: coder" alongside main's other actions.
const spawnEvents = db
.prepare(
"SELECT 1 FROM events WHERE agent_id = ? AND event_type = 'PreToolUse' AND tool_name = 'Agent'"
)
.all(mainAgentId);
assert.equal(spawnEvents.length, 1);
} finally {
fs.unlinkSync(tmpFile);
try {
fs.unlinkSync(tmpFile.replace(/\.jsonl$/, ".meta.json"));
} catch {
/* ignore */
}
}
});
it("is idempotent — re-running does not duplicate events", async () => {
const tmpFile = path.join(os.tmpdir(), `agent-idem-${Date.now()}-${process.pid}.jsonl`);
writeSubagentJsonl(tmpFile, buildSubagentLines("reviewer"));
writeMetaJson(tmpFile.replace(/\.jsonl$/, ".meta.json"), "reviewer");
try {
const data = await importHistory.parseSubagentFile(tmpFile);
importHistory.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, data);
const subId = `${sessionId}-jsonl-${data.agentId}`;
const before = db.prepare("SELECT COUNT(*) AS c FROM events WHERE agent_id = ?").get(subId).c;
// Second run — should be a no-op.
importHistory.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, data);
const after = db.prepare("SELECT COUNT(*) AS c FROM events WHERE agent_id = ?").get(subId).c;
assert.equal(after, before, "idempotent re-import — no new rows");
} finally {
fs.unlinkSync(tmpFile);
try {
fs.unlinkSync(tmpFile.replace(/\.jsonl$/, ".meta.json"));
} catch {
/* ignore */
}
}
});
it("merges into a live subagent when one matches — no JSONL-keyed duplicate row", async () => {
// Simulate a live PreToolUse Agent hook having pre-created a subagent row.
const liveSubId = "live-uuid-xyz";
const startedAt = "2026-04-28T10:00:00.000Z";
stmts.insertAgent.run(
liveSubId,
sessionId,
"Live Coder",
"subagent",
"live-coder",
"completed",
"task",
mainAgentId,
null
);
db.prepare("UPDATE agents SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?").run(
startedAt,
startedAt,
startedAt,
liveSubId
);
const tmpFile = path.join(os.tmpdir(), `agent-live-${Date.now()}-${process.pid}.jsonl`);
const lines = buildSubagentLines("live-coder");
writeSubagentJsonl(tmpFile, lines);
writeMetaJson(tmpFile.replace(/\.jsonl$/, ".meta.json"), "live-coder");
try {
const data = await importHistory.parseSubagentFile(tmpFile);
importHistory.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, data);
const jsonlSubId = `${sessionId}-jsonl-${data.agentId}`;
assert.equal(
stmts.getAgent.get(jsonlSubId),
undefined,
"no JSONL-keyed row when a live subagent absorbed the events"
);
const eventsUnderLive = db
.prepare(
"SELECT 1 FROM events WHERE agent_id = ? AND event_type IN ('PreToolUse', 'PostToolUse')"
)
.all(liveSubId);
assert.ok(eventsUnderLive.length >= 4, "events should attach to the live subagent's id");
} finally {
fs.unlinkSync(tmpFile);
try {
fs.unlinkSync(tmpFile.replace(/\.jsonl$/, ".meta.json"));
} catch {
/* ignore */
}
}
});
});
// ── Per-subagent model token attribution (issue #185) ──────────────────
describe("scanAndImportSubagents — per-subagent model token attribution", () => {
it("buckets each subagent's tokens under its OWN model, skipping the parent model", async () => {
const sessionId = "sess-185-tiered";
// Orchestrator on Opus; subagents tiered to Sonnet + Haiku, plus one on the
// SAME model as the parent (Opus) to verify that bucket is intentionally skipped.
stmts.insertSession.run(sessionId, "Tiered", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
`${sessionId}-main`,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{
id: "hq",
lines: buildModelSubLines("qa", "claude-haiku-4-5-20251001", { input: 1000, output: 500 }),
},
{
id: "se",
lines: buildModelSubLines("engineer", "claude-sonnet-4-6", { input: 2000, output: 800 }),
},
{
id: "op",
lines: buildModelSubLines("planner", "claude-opus-4-8", { input: 9999, output: 9999 }),
},
]);
try {
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
const rows = stmts.getTokensBySession.all(sessionId);
const byModel = Object.fromEntries(rows.map((r) => [r.model, r]));
const models = Object.keys(byModel).sort();
// Haiku + Sonnet buckets are written under their own models.
assert.deepEqual(models, ["claude-haiku-4-5-20251001", "claude-sonnet-4-6"]);
assert.equal(byModel["claude-haiku-4-5-20251001"].input_tokens, 1000);
assert.equal(byModel["claude-haiku-4-5-20251001"].output_tokens, 500);
assert.equal(byModel["claude-sonnet-4-6"].input_tokens, 2000);
assert.equal(byModel["claude-sonnet-4-6"].output_tokens, 800);
// The Opus subagent's tokens are NOT written here — that bucket belongs to
// the main-transcript writer; double-writing it would inflate via baseline.
assert.equal(byModel["claude-opus-4-8"], undefined, "parent-model bucket must be skipped");
// Cost is priced at the real (cheaper) per-subagent models, never Opus.
const cost = calculateCost(rows, stmts.listPricing.all());
const costModels = cost.breakdown.map((b) => b.model).sort();
assert.deepEqual(costModels, ["claude-haiku-4-5-20251001", "claude-sonnet-4-6"]);
assert.ok(cost.total_cost > 0);
// Each subagent row records its real model in metadata.
const hq = stmts.getAgent.get(`${sessionId}-jsonl-hq`);
assert.equal(JSON.parse(hq.metadata).model, "claude-haiku-4-5-20251001");
const se = stmts.getAgent.get(`${sessionId}-jsonl-se`);
assert.equal(JSON.parse(se.metadata).model, "claude-sonnet-4-6");
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it("re-running does not inflate token buckets (idempotent per-model write)", async () => {
const sessionId = "sess-185-idem";
stmts.insertSession.run(sessionId, "Idem", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
`${sessionId}-main`,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{
id: "hq",
lines: buildModelSubLines("qa", "claude-haiku-4-5-20251001", { input: 1000, output: 500 }),
},
]);
try {
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
const rows = stmts.getTokensBySession.all(sessionId);
const haiku = rows.find((r) => r.model === "claude-haiku-4-5-20251001");
// getTokensBySession already returns effective totals (current + baseline).
// Re-running must not double them — append-only subagent JSONLs never drop.
assert.equal(haiku.input_tokens, 1000);
assert.equal(haiku.output_tokens, 500);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it("skips every model the MAIN transcript used, not just the latest (mid-session /model switch)", async () => {
const sessionId = "sess-185-switch";
// Orchestrator switched Opus → Sonnet mid-session; session.model holds the
// latest (Sonnet), but the main transcript wrote BOTH. A subagent on the
// earlier Opus must still be skipped to avoid colliding with the main writer.
stmts.insertSession.run(sessionId, "Switch", "active", "/tmp", "claude-sonnet-4-6", null);
stmts.insertAgent.run(
`${sessionId}-main`,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{
id: "op",
lines: buildModelSubLines("planner", "claude-opus-4-8", { input: 5000, output: 5000 }),
},
{
id: "hq",
lines: buildModelSubLines("qa", "claude-haiku-4-5-20251001", { input: 100, output: 50 }),
},
]);
try {
// parentModels carries BOTH orchestrator models (as hooks.js would pass).
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath, {
parentModels: ["claude-sonnet-4-6", "claude-opus-4-8"],
});
const models = stmts.getTokensBySession
.all(sessionId)
.map((r) => r.model)
.sort();
// Only Haiku is written; both orchestrator models (Sonnet + the earlier
// Opus) are skipped even though Opus != session.model.
assert.deepEqual(models, ["claude-haiku-4-5-20251001"]);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it("backfills a live subagent row's model from its own transcript", async () => {
const sessionId = "sess-185-live";
const startedAt = "2026-05-01T10:00:00.000Z";
stmts.insertSession.run(sessionId, "Live", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
`${sessionId}-main`,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
// Live subagent created by the PreToolUse "Agent" hook — no model recorded.
const liveId = "live-185-qa";
stmts.insertAgent.run(
liveId,
sessionId,
"QA",
"subagent",
"qa",
"working",
"task",
`${sessionId}-main`,
null
);
db.prepare("UPDATE agents SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?").run(
startedAt,
startedAt,
startedAt,
liveId
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{
id: "qa1",
agentType: "qa",
lines: buildModelSubLines(
"qa",
"claude-haiku-4-5-20251001",
{ input: 10, output: 5 },
startedAt
),
},
]);
try {
assert.equal(stmts.getAgent.get(liveId).metadata, null, "live row starts with no model");
await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
const meta = JSON.parse(stmts.getAgent.get(liveId).metadata || "{}");
assert.equal(meta.model, "claude-haiku-4-5-20251001", "live row backfilled with real model");
// No JSONL-keyed duplicate row was created (events merged into the live row).
assert.equal(stmts.getAgent.get(`${sessionId}-jsonl-qa1`), undefined);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});
describe("scanAndImportSubagents — nested subagent hierarchy", () => {
// A subagent's own transcript records each child it spawned via the Task tool
// as `toolUseResult.agentId`. Build a spawner file that claims `childIds`.
function buildSpawnerLines(childIds, startedAt = "2026-06-01T10:00:00.000Z") {
const lines = [
{ type: "user", timestamp: startedAt, message: { content: [{ type: "text", text: "go" }] } },
];
childIds.forEach((cid, i) => {
const ts = `2026-06-01T10:00:0${i + 1}.000Z`;
lines.push({
type: "assistant",
timestamp: ts,
message: {
model: "claude-opus-4-8",
content: [{ type: "tool_use", id: `toolu_task_${cid}`, name: "Task", input: {} }],
},
});
lines.push({
type: "user",
timestamp: ts,
toolUseResult: { agentId: cid, status: "completed" },
message: {
content: [{ type: "tool_result", tool_use_id: `toolu_task_${cid}`, content: "spawned" }],
},
});
});
return lines;
}
function buildLeafLines(startedAt = "2026-06-01T10:01:00.000Z") {
return [
{
type: "user",
timestamp: startedAt,
message: { content: [{ type: "text", text: "work" }] },
},
{
type: "assistant",
timestamp: startedAt,
message: {
model: "claude-haiku-4-5-20251001",
content: [{ type: "tool_use", id: "toolu_leaf", name: "Read", input: {} }],
},
},
];
}
it("nests subagents under their true spawner instead of flattening to main", async () => {
const sessionId = "sess-nested-tree";
const mainAgentId = `${sessionId}-main`;
stmts.insertSession.run(sessionId, "Nested", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
mainAgentId,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
// main → orch; orch → leafA, leafB; main → solo
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{ id: "orch", lines: buildSpawnerLines(["leafA", "leafB"]) },
{ id: "leafA", lines: buildLeafLines() },
{ id: "leafB", lines: buildLeafLines() },
{ id: "solo", lines: buildLeafLines() },
]);
try {
const res = await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
assert.equal(res.reparented, 2, "leafA + leafB repointed under orch");
const parentOf = (id) => stmts.getAgent.get(`${sessionId}-jsonl-${id}`).parent_agent_id;
assert.equal(parentOf("orch"), mainAgentId, "orch is a direct child of main");
assert.equal(parentOf("solo"), mainAgentId, "solo stays under main");
assert.equal(parentOf("leafA"), `${sessionId}-jsonl-orch`, "leafA nests under orch");
assert.equal(parentOf("leafB"), `${sessionId}-jsonl-orch`, "leafB nests under orch");
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it("is idempotent — a second scan repoints nothing", async () => {
const sessionId = "sess-nested-idem";
const mainAgentId = `${sessionId}-main`;
stmts.insertSession.run(sessionId, "NestedIdem", "active", "/tmp", "claude-opus-4-8", null);
stmts.insertAgent.run(
mainAgentId,
sessionId,
"Main",
"main",
null,
"working",
null,
null,
null
);
const { transcriptPath, base } = buildSubagentDir(sessionId, [
{ id: "orch2", lines: buildSpawnerLines(["leafC"]) },
{ id: "leafC", lines: buildLeafLines() },
]);
try {
const first = await importHistory.scanAndImportSubagents(dbModule, sessionId, transcriptPath);
assert.equal(first.reparented, 1);
const second = await importHistory.scanAndImportSubagents(
dbModule,
sessionId,
transcriptPath
);
assert.equal(second.reparented, 0, "no re-parenting on the second pass");
assert.equal(
stmts.getAgent.get(`${sessionId}-jsonl-leafC`).parent_agent_id,
`${sessionId}-jsonl-orch2`
);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});
describe("events dedup index", () => {
it("has a (agent_id, event_type) index so per-tool-event dedup is not a full scan", () => {
// importSubagentFromJsonl dedups every tool event with
// "WHERE agent_id = ? AND event_type = ? AND data LIKE '%tool_use_id%'".
// Without this index each dedup full-scans the events table; on a large DB a
// single re-import (e.g. the startup sweep touching a subagent-heavy session)
// takes tens of seconds and blocks the event loop. Guard it from regressing.
const row = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_events_agent_type'"
)
.get();
assert.ok(row, "idx_events_agent_type must exist to keep subagent dedup indexed");
});
});
@@ -0,0 +1,146 @@
/**
* @file Tests the one-time backfill that stamps metadata.tokens onto subagent
* rows predating per-agent cost tracking.
*
* A historical session whose transcript never changes again is mtime-skipped by
* the continuous sync, so its subagents (imported before per-agent cost existed)
* would never gain a tokens bucket and their cards would show no cost. The
* startup backfill re-parses those transcripts and stamps the metadata — WITHOUT
* touching session token_usage. This suite verifies:
*
* 1. A pre-feature subagent row (no tokens key) gets its metadata.tokens
* stamped from its transcript, so attachAgentCosts can price it.
* 2. The backfill is metadata-only: it does not create/alter token_usage rows.
* 3. It is self-limiting — a second run stamps nothing new.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `dashboard-subcost-bf-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const importHistory = require("../../scripts/import-history");
const { attachAgentCosts } = require("../routes/pricing");
const SESSION = "dddddddd-4444-4444-8444-dddddddddddd";
const MAIN = `${SESSION}-main`;
const SUB_ID = "aaaa1111-bbbb-2222-cccc-333344445555";
const SUB_MODEL = "claude-haiku-4-5-20251001";
let tmpDir;
let transcriptPath;
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subcost-bf-"));
// Lay out the on-disk transcript tree the backfill walks:
// <proj>/<session>.jsonl (main transcript path)
// <proj>/<session>/subagents/agent-<id>.jsonl
transcriptPath = path.join(tmpDir, `${SESSION}.jsonl`);
fs.writeFileSync(transcriptPath, "");
const subDir = path.join(tmpDir, SESSION, "subagents");
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(
path.join(subDir, `agent-${SUB_ID}.jsonl`),
[
{
type: "user",
sessionId: SESSION,
timestamp: "2026-04-18T12:00:00.000Z",
message: { content: "go" },
},
{
type: "assistant",
sessionId: SESSION,
timestamp: "2026-04-18T12:00:05.000Z",
message: {
model: SUB_MODEL,
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: 2_000_000, output_tokens: 1_000_000 },
},
},
]
.map((o) => JSON.stringify(o))
.join("\n")
);
// Session with transcript_path set, and a PRE-FEATURE subagent row: metadata
// has a model but NO tokens key (exactly what old imports produced).
stmts.insertSession.run(
SESSION,
"Backfill session",
"completed",
"/tmp/x",
"claude-opus-4-8",
null
);
db.prepare("UPDATE sessions SET transcript_path = ? WHERE id = ?").run(transcriptPath, SESSION);
stmts.insertAgent.run(MAIN, SESSION, "Main Agent", "main", null, "completed", null, null, null);
stmts.insertAgent.run(
`${SESSION}-jsonl-${SUB_ID}`,
SESSION,
"general-purpose",
"subagent",
"general-purpose",
"completed",
"recon",
MAIN,
JSON.stringify({ imported: true, source: "jsonl", model: SUB_MODEL })
);
// Deterministic Haiku pricing: $1 in / $5 out per MTok.
stmts.upsertPricing.run("claude-haiku%", "Claude Haiku 4.5", 1, 5, 0.1, 1.25, 2, 0, 0);
});
describe("subagent token backfill", () => {
it("stamps metadata.tokens on a pre-feature subagent row from its transcript", async () => {
const before = stmts.getAgent.get(`${SESSION}-jsonl-${SUB_ID}`);
assert.ok(!/"tokens":/.test(before.metadata), "row starts without a tokens key");
const res = await importHistory.backfillSubagentTokenMetadata(dbModule);
assert.ok(res.stamped >= 1, "at least one subagent re-parsed");
const row = stmts.getAgent.get(`${SESSION}-jsonl-${SUB_ID}`);
const meta = JSON.parse(row.metadata);
assert.ok(Array.isArray(meta.tokens) && meta.tokens.length === 1, "tokens stamped");
assert.equal(meta.tokens[0].input_tokens, 2_000_000);
assert.equal(meta.tokens[0].output_tokens, 1_000_000);
// Now priceable: 2M in @ $1 + 1M out @ $5 = $2 + $5 = $7.
const withCosts = attachAgentCosts(stmts.listAgentsBySession.all(SESSION));
const sub = withCosts.find((a) => a.id === `${SESSION}-jsonl-${SUB_ID}`);
assert.equal(sub.cost, 7);
});
it("is metadata-only — it creates no token_usage rows", () => {
const rows = db
.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = ?")
.get(SESSION);
assert.equal(rows.n, 0, "session token_usage untouched by the backfill");
});
it("is self-limiting — a second run stamps nothing new", async () => {
const res = await importHistory.backfillSubagentTokenMetadata(dbModule);
// The one session no longer matches the driving query (its subagent now has
// a tokens key), so no session is re-scanned.
assert.equal(res.sessions, 0);
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* @file Tests that a subagent's OWN cost is derived and surfaced per-agent.
*
* Subagent cards used to show the whole session's cost, which reads as if that
* one subagent cost the entire session's spend. The importer now stamps each
* subagent's own token buckets into its metadata, and the agent-list endpoints
* compute a per-agent `cost` from them (priced at current rates, like session
* cost). This suite verifies:
*
* 1. importSubagentFromJsonl stores the subagent's own token buckets in
* agent.metadata.tokens.
* 2. attachAgentCosts computes that subagent's cost from those buckets and the
* current pricing rules — independent of the session total.
* 3. A main agent (no per-agent tokens) gets cost 0 (its cost is the session
* total, shown separately).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `dashboard-subagent-cost-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
const importHistory = require("../../scripts/import-history");
const { attachAgentCosts, calculateCost } = require("../routes/pricing");
const SESSION = "cccccccc-3333-4333-8333-cccccccccccc";
const MAIN = `${SESSION}-main`;
const SUB_AGENT_ID = "11112222-3333-4444-5555-666677778888";
const SUB_MODEL = "claude-haiku-4-5-20251001";
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
function writeJsonl(filePath, lines) {
fs.writeFileSync(filePath, lines.map((o) => JSON.stringify(o)).join("\n"));
}
/** A subagent transcript with one assistant turn carrying known Haiku usage. */
function subagentLines() {
const base = "2026-04-18T12:00:00.000Z";
return [
{ type: "user", sessionId: SESSION, timestamp: base, message: { content: "do the thing" } },
{
type: "assistant",
sessionId: SESSION,
timestamp: "2026-04-18T12:00:05.000Z",
message: {
model: SUB_MODEL,
content: [{ type: "text", text: "done" }],
usage: {
input_tokens: 1_000_000,
output_tokens: 500_000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
];
}
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subcost-"));
// Session + main agent so the subagent FK holds.
stmts.insertSession.run(
SESSION,
"Cost test session",
"completed",
"/tmp/x",
"claude-opus-4-8",
null
);
stmts.insertAgent.run(MAIN, SESSION, "Main Agent", "main", null, "completed", null, null, null);
// Deterministic Haiku pricing: $1 in / $5 out per MTok.
stmts.upsertPricing.run("claude-haiku%", "Claude Haiku 4.5", 1, 5, 0.1, 1.25, 2, 0, 0);
});
describe("per-subagent cost", () => {
it("stamps the subagent's own token buckets into its metadata on import", async () => {
const file = path.join(tmpDir, `agent-${SUB_AGENT_ID}.jsonl`);
writeJsonl(file, subagentLines());
const subData = await importHistory.parseSubagentFile(file);
assert.ok(subData, "subData parsed");
importHistory.importSubagentFromJsonl(dbModule, SESSION, MAIN, subData);
const row = stmts.getAgent.get(`${SESSION}-jsonl-${SUB_AGENT_ID}`);
assert.ok(row, "jsonl subagent row created");
const meta = JSON.parse(row.metadata);
assert.ok(Array.isArray(meta.tokens) && meta.tokens.length === 1, "one token bucket stored");
assert.equal(meta.tokens[0].model, SUB_MODEL);
assert.equal(meta.tokens[0].input_tokens, 1_000_000);
assert.equal(meta.tokens[0].output_tokens, 500_000);
});
it("computes the subagent's own cost from its buckets, not the session total", () => {
const agents = stmts.listAgentsBySession.all(SESSION);
const withCosts = attachAgentCosts(agents);
const sub = withCosts.find((a) => a.id === `${SESSION}-jsonl-${SUB_AGENT_ID}`);
const main = withCosts.find((a) => a.id === MAIN);
// 1M input @ $1 + 0.5M output @ $5 = $1 + $2.50 = $3.50.
assert.equal(sub.cost, 3.5);
// Cross-check against calculateCost directly.
const rules = stmts.listPricing.all();
assert.equal(
calculateCost(JSON.parse(sub.metadata).tokens, rules, "2026-04-18").total_cost,
3.5
);
// Main agent carries no per-agent tokens → 0 (its cost is the session total).
assert.equal(main.cost, 0);
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* @file Tests for replaceTokenUsage's compaction baseline semantics.
*
* The effective total for a bucket is `live + baseline`. It must behave as a
* monotonic HIGH-WATER MARK: never decrease (so a compaction that shrinks the
* transcript doesn't lose usage), but never inflate past the largest value ever
* seen (so two writers hitting the same bucket with different scopes — the live
* hook writer stores main-only tokens, importSession stores main+subagents —
* can't ratchet the baseline upward on every downward fluctuation).
*
* Regression guard for the runaway that inflated one 26-day session's baseline
* to ~11× its real usage (dashboard total $22.5k vs true ~$15.2k).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `dashboard-token-baseline-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { db, stmts } = dbModule;
after(() => {
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
// Write a cache_read value for a fixed bucket; return effective (live+baseline).
function writeCacheRead(sessionId, cacheRead) {
stmts.replaceTokenUsage.run(
sessionId,
"claude-opus-4-8",
"standard",
"global",
"standard",
0, // input
0, // output
cacheRead,
0, // cache_write
0, // cache_write_1h
0, // web_search
0, // web_fetch
0 // code_execution
);
}
function effectiveCacheRead(sessionId) {
const r = stmts.getTokensBySession.all(sessionId).find((x) => x.model === "claude-opus-4-8");
return r ? r.cache_read_tokens : 0;
}
function rawRow(sessionId) {
return db
.prepare(
"SELECT cache_read_tokens, baseline_cache_read FROM token_usage WHERE session_id = ? AND model = 'claude-opus-4-8'"
)
.get(sessionId);
}
describe("replaceTokenUsage — high-water-mark baseline", () => {
before(() => {
for (const id of ["hw-grow", "hw-idem", "hw-shrink", "hw-altern", "hw-recover"]) {
try {
stmts.insertSession.run(id, "t", "active", "/tmp", null, null);
} catch {
/* exists */
}
}
});
it("monotonic growth: effective follows the latest, no baseline", () => {
writeCacheRead("hw-grow", 100);
assert.equal(effectiveCacheRead("hw-grow"), 100);
writeCacheRead("hw-grow", 300);
assert.equal(effectiveCacheRead("hw-grow"), 300);
assert.equal(rawRow("hw-grow").baseline_cache_read, 0);
});
it("idempotent: rewriting the same value never changes effective", () => {
writeCacheRead("hw-idem", 500);
writeCacheRead("hw-idem", 500);
writeCacheRead("hw-idem", 500);
assert.equal(effectiveCacheRead("hw-idem"), 500);
assert.equal(rawRow("hw-idem").baseline_cache_read, 0);
});
it("decrease preserves the max (compaction never loses usage)", () => {
writeCacheRead("hw-shrink", 300);
writeCacheRead("hw-shrink", 100); // transcript shrank
assert.equal(effectiveCacheRead("hw-shrink"), 300, "effective must not drop below the peak");
const row = rawRow("hw-shrink");
assert.equal(row.cache_read_tokens, 100);
assert.equal(row.baseline_cache_read, 200); // 100 live + 200 baseline = 300
});
it("writer alternation does NOT run away (the bug)", () => {
// Two writers: 'big' (main+subagents) and 'small' (main-only) on one bucket.
for (let i = 0; i < 20; i++) {
writeCacheRead("hw-altern", 800); // big writer
writeCacheRead("hw-altern", 700); // small writer
}
// Effective must stay pinned at the true max (800), NOT accumulate.
assert.equal(effectiveCacheRead("hw-altern"), 800, "effective must stay at the peak, not grow");
assert.equal(rawRow("hw-altern").baseline_cache_read, 100); // 700 + 100 = 800
});
it("recovers upward: a new higher value zeroes stale baseline", () => {
writeCacheRead("hw-recover", 300);
writeCacheRead("hw-recover", 100); // baseline=200, eff=300
writeCacheRead("hw-recover", 500); // new peak
assert.equal(effectiveCacheRead("hw-recover"), 500);
assert.equal(rawRow("hw-recover").baseline_cache_read, 0);
});
});
@@ -0,0 +1,210 @@
/**
* @file Tests that TranscriptCache caps the size of each per-entry array
* (turnDurations / errors / compaction.entries / usageExtras.*) so a long
* session cannot grow a single cache entry without bound.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");
const TranscriptCache = require("../lib/transcript-cache");
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tc-bounded-"));
});
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeJsonl(name, lines) {
const p = path.join(tmpDir, name);
fs.writeFileSync(p, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
return p;
}
describe("TranscriptCache._trimArray", () => {
it("exists and trims arrays to the given max length, keeping the tail", () => {
const cache = new TranscriptCache();
assert.equal(typeof cache._trimArray, "function");
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
cache._trimArray(arr, 3);
assert.deepEqual(arr, [8, 9, 10]);
});
it("is a no-op when array is within the cap", () => {
const cache = new TranscriptCache();
const arr = [1, 2, 3];
cache._trimArray(arr, 5);
assert.deepEqual(arr, [1, 2, 3]);
});
it("handles null/undefined safely", () => {
const cache = new TranscriptCache();
assert.doesNotThrow(() => cache._trimArray(null, 5));
assert.doesNotThrow(() => cache._trimArray(undefined, 5));
});
});
describe("TranscriptCache.extract — array caps", () => {
it("caps turnDurations at MAX_ARRAY_LEN on full read, keeping the tail", () => {
// 1500 turn_duration entries, ascending timestamps
const lines = [];
for (let i = 0; i < 1500; i++) {
lines.push({
type: "system",
subtype: "turn_duration",
durationMs: i + 1,
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
});
}
const p = writeJsonl("turns.jsonl", lines);
process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN = "100";
// Re-require fresh to pick up the env override
delete require.cache[require.resolve("../lib/transcript-cache")];
const Fresh = require("../lib/transcript-cache");
const cache = new Fresh();
const result = cache.extract(p);
assert.ok(result, "expected non-null result");
assert.equal(result.turnDurations.length, 100);
// Tail-kept: durationMs should be 1401..1500
assert.equal(result.turnDurations[0].durationMs, 1401);
assert.equal(result.turnDurations[99].durationMs, 1500);
delete process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN;
delete require.cache[require.resolve("../lib/transcript-cache")];
});
it("caps errors and compaction.entries on full read", () => {
const lines = [];
for (let i = 0; i < 300; i++) {
lines.push({
isApiErrorMessage: true,
error: "rate_limit",
message: { content: [{ text: `err-${i}` }] },
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
});
lines.push({
isCompactSummary: true,
uuid: `c-${i}`,
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
});
}
const p = writeJsonl("err-compact.jsonl", lines);
process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN = "50";
delete require.cache[require.resolve("../lib/transcript-cache")];
const Fresh = require("../lib/transcript-cache");
const cache = new Fresh();
const result = cache.extract(p);
assert.equal(result.errors.length, 50);
assert.equal(result.compaction.entries.length, 50);
assert.equal(
result.compaction.count,
300,
"count must reflect ALL parsed entries, not just retained"
);
delete process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN;
delete require.cache[require.resolve("../lib/transcript-cache")];
});
it("incremental merge respects cap (append to existing capped entry)", () => {
process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN = "100";
delete require.cache[require.resolve("../lib/transcript-cache")];
const Fresh = require("../lib/transcript-cache");
const cache = new Fresh();
// First batch: 80 turns
const linesA = [];
for (let i = 0; i < 80; i++) {
linesA.push({
type: "system",
subtype: "turn_duration",
durationMs: i + 1,
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
});
}
const p = writeJsonl("incr.jsonl", linesA);
let result = cache.extract(p);
assert.equal(result.turnDurations.length, 80);
// Append 50 more — total 130, cache should retain only last 100
const fd = fs.openSync(p, "a");
for (let i = 80; i < 130; i++) {
const line =
JSON.stringify({
type: "system",
subtype: "turn_duration",
durationMs: i + 1,
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
}) + "\n";
fs.writeSync(fd, line);
}
fs.closeSync(fd);
result = cache.extract(p);
assert.equal(result.turnDurations.length, 100);
// Tail check: last entry should be durationMs=130
assert.equal(result.turnDurations[99].durationMs, 130);
// Head should be durationMs=31 (130 - 100 + 1)
assert.equal(result.turnDurations[0].durationMs, 31);
delete process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN;
delete require.cache[require.resolve("../lib/transcript-cache")];
});
});
describe("TranscriptCache._set — single storage", () => {
it("cache entry contains ONLY {mtimeMs, size, bytesRead, result}", () => {
const p = writeJsonl("single.jsonl", [
{
type: "system",
subtype: "turn_duration",
durationMs: 100,
timestamp: "2026-01-01T00:00:00Z",
},
]);
const cache = new TranscriptCache();
cache.extract(p);
const entry = cache._cache.get(p);
assert.ok(entry, "entry should be cached");
const keys = Object.keys(entry).sort();
assert.deepEqual(keys, ["bytesRead", "mtimeMs", "result", "size"]);
});
it("does not store duplicate top-level errors/turnDurations/compaction", () => {
const p = writeJsonl("dup.jsonl", [
{
type: "system",
subtype: "turn_duration",
durationMs: 1,
timestamp: "2026-01-01T00:00:00Z",
},
{
isApiErrorMessage: true,
error: "x",
message: { content: [{ text: "y" }] },
timestamp: "2026-01-01T00:00:01Z",
},
{ isCompactSummary: true, uuid: "u1", timestamp: "2026-01-01T00:00:02Z" },
]);
const cache = new TranscriptCache();
cache.extract(p);
const entry = cache._cache.get(p);
assert.equal(entry.errors, undefined);
assert.equal(entry.turnDurations, undefined);
assert.equal(entry.compaction, undefined);
assert.equal(entry.tokensByModel, undefined);
assert.equal(entry.usageExtras, undefined);
assert.equal(entry.thinkingBlockCount, undefined);
assert.equal(entry.latestModel, undefined);
});
});
@@ -0,0 +1,33 @@
/**
* @file Verifies the sweep queries used in server/index.js have been migrated
* from json_extract(events.data,...) to sessions.transcript_path. Tests by
* checking the SQL strings that appear in the file rather than running the
* full setInterval the unit-level guarantee is what matters here.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const SRC = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8");
describe("server/index.js sweep queries", () => {
it("does NOT contain json_extract on events.data for transcript_path", () => {
const matches = SRC.match(/json_extract\([^)]*events?\.data[^)]*transcript_path/gi) || [];
assert.equal(
matches.length,
0,
`expected zero events.data json_extract for transcript_path; found:\n${matches.join("\n")}`
);
});
it("queries sessions.transcript_path for the active sweep", () => {
assert.match(
SRC,
/FROM sessions[^;]*WHERE[^;]*status\s*=\s*'active'[^;]*transcript_path/is,
"expected a SELECT from sessions with status='active' and transcript_path"
);
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* @file transcript-sender.test.js
* @description Unit tests for classifyTranscriptSender the transcript viewer
* must attribute each JSONL line to its TRUE sender, not blanket-label every
* `type:"user"` line as the human. Cases mirror real Claude Code transcripts:
* tool results, harness task-notifications, /loop (isMeta) re-injections, and a
* subagent's orchestrator-assigned task. (Reported transcript mis-attribution.)
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const os = require("node:os");
const fs = require("node:fs");
// Point the db at a throwaway file before requiring the router (it pulls in db).
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { classifyTranscriptSender } = require("../routes/sessions");
const { db } = require("../db");
after(() => {
if (db) db.close();
for (const s of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + s);
} catch {
/* ignore */
}
}
});
// Shapes lifted from real ~/.claude transcripts.
const realUser = (text) => ({
type: "user",
message: { role: "user", content: text },
promptSource: "user_input",
origin: "cli",
});
const toolResult = () => ({
type: "user",
message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }] },
toolUseResult: { stdout: "ok" },
});
const assistant = () => ({ type: "assistant", message: { role: "assistant", content: [] } });
describe("classifyTranscriptSender — main transcript", () => {
it("real human message → user", () => {
assert.equal(classifyTranscriptSender(realUser("spin up a team of agents"), false), "user");
});
it("tool result (toolUseResult / tool_result content) → tool", () => {
assert.equal(classifyTranscriptSender(toolResult(), false), "tool");
});
it("harness task-notification → system", () => {
const e = {
type: "user",
message: { role: "user", content: "<task-notification>\n<task-id>abc</task-id>\n" },
promptSource: "task_notification",
origin: "system",
};
assert.equal(classifyTranscriptSender(e, false), "system");
});
it("[SYSTEM NOTIFICATION …] background-task banner → system", () => {
const e = {
type: "user",
message: {
role: "user",
content:
"[SYSTEM NOTIFICATION - NOT USER INPUT]\nThis is an automated background-task event…\n<task-notification>…",
},
};
assert.equal(classifyTranscriptSender(e, false), "system");
});
it("/loop re-injection (isMeta) → system", () => {
const e = {
type: "user",
isMeta: true,
message: { role: "user", content: "Sonnet cognition agent last one — stitch the brief" },
};
assert.equal(classifyTranscriptSender(e, false), "system");
});
it("assistant turn → assistant", () => {
assert.equal(classifyTranscriptSender(assistant(), false), "assistant");
});
it("@agent mention typed by the human → user", () => {
assert.equal(classifyTranscriptSender(realUser("@agent-ai-engineer hi"), false), "user");
});
it("local slash-command (type=system) → user", () => {
assert.equal(
classifyTranscriptSender(
{ type: "system", subtype: "local_command", content: "/color" },
false
),
"user"
);
});
});
describe("classifyTranscriptSender — subagent transcript", () => {
it("orchestrator-assigned task (no promptSource/origin) → orchestrator", () => {
const task = {
type: "user",
isSidechain: true,
agentId: "a484",
message: { role: "user", content: "Light research/synthesis task. Write a synthesis…" },
};
assert.equal(classifyTranscriptSender(task, true), "orchestrator");
});
it("tool result inside a subagent → tool", () => {
assert.equal(classifyTranscriptSender(toolResult(), true), "tool");
});
it("human directly messaging the subagent (has provenance) → user", () => {
const direct = {
type: "user",
isSidechain: true,
agentId: "a484",
message: { role: "user", content: "actually, focus on cognition" },
promptSource: "user_input",
origin: "cli",
};
assert.equal(classifyTranscriptSender(direct, true), "user");
});
it("assistant turn in a subagent → assistant", () => {
assert.equal(classifyTranscriptSender(assistant(), true), "assistant");
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* @file Branch- and fork-aware tests for getUpdatesStatus(). Each scenario
* builds throw-away git repos in a tmp dir and asserts the payload shape is
* accurate to the user's situation. skipFetch:true keeps these tests offline.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const { getUpdatesStatus } = require("../lib/update-check");
function git(cwd, args) {
return execFileSync("git", args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).trim();
}
function makeBareRemote(parent, name) {
const repo = path.join(parent, `${name}.git`);
fs.mkdirSync(repo, { recursive: true });
// -c init.defaultBranch=master works on every git that supports -c init.*,
// i.e. far older than --initial-branch.
execFileSync("git", ["-c", "init.defaultBranch=master", "init", "--bare", repo], {
stdio: "ignore",
});
return repo;
}
function makeWorkingRepo(parent, dir, originUrl) {
const repo = path.join(parent, dir);
fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { stdio: "ignore" });
fs.writeFileSync(path.join(repo, "README.md"), "fixture\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
git(repo, ["remote", "add", "origin", originUrl]);
git(repo, ["push", "-u", "origin", "master"]);
return repo;
}
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "updcheck-"));
});
after(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore
}
});
describe("getUpdatesStatus — local on canonical default branch", () => {
it("with origin only: tracks_canonical=true, command pulls --ff-only", async () => {
const remote = makeBareRemote(tmpDir, "canon1");
const work = makeWorkingRepo(tmpDir, "work1", remote);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.git_repo, true);
assert.equal(result.canonical_remote, "origin");
assert.equal(result.remote_ref, "origin/master");
assert.equal(result.current_branch, "master");
assert.equal(result.tracking_upstream, "origin/master");
assert.equal(result.tracks_canonical, true);
assert.equal(result.situation, "tracking_canonical");
assert.equal(result.situation_note, null);
assert.match(result.manual_command, /git pull --ff-only/);
});
});
describe("getUpdatesStatus — local on a feature branch", () => {
it("does NOT suggest git pull (would pull feature, not master)", async () => {
const remote = makeBareRemote(tmpDir, "canon2");
const work = makeWorkingRepo(tmpDir, "work2", remote);
git(work, ["checkout", "-b", "feature/foo"]);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.current_branch, "feature/foo");
assert.equal(result.tracks_canonical, false);
assert.equal(result.situation, "feature_branch");
assert.ok(result.situation_note, "expected a situation_note for feature branches");
assert.match(result.manual_command, /git fetch origin/);
assert.doesNotMatch(
result.manual_command,
/git pull/,
"must not suggest git pull — would pull feature branch, not canonical"
);
assert.doesNotMatch(
result.manual_command,
/git merge --ff-only/,
"must not auto-merge canonical into the feature branch"
);
});
});
describe("getUpdatesStatus — fork layout (origin = fork, upstream = canonical)", () => {
it("ignores a stray upstream remote and tracks origin only", async () => {
const fork = makeBareRemote(tmpDir, "fork3");
const upstream = makeBareRemote(tmpDir, "upstream3");
const work = makeWorkingRepo(tmpDir, "work3", fork);
// Add a second remote AFTER the working clone so origin stays the fork.
git(work, ["remote", "add", "upstream", upstream]);
git(work, ["push", "upstream", "master"]);
const result = await getUpdatesStatus(work, { skipFetch: true });
// This build tracks its own repository. A remote named `upstream` pointing
// at somebody else's copy must never become the update source.
assert.equal(result.canonical_remote, "origin");
assert.equal(result.remote_ref, "origin/master");
assert.equal(result.current_branch, "master");
assert.equal(result.tracking_upstream, "origin/master");
assert.equal(result.tracks_canonical, true);
assert.doesNotMatch(result.manual_command, /upstream/);
});
});
describe("getUpdatesStatus — detached HEAD", () => {
it("reports detached_head and only suggests fetch", async () => {
const remote = makeBareRemote(tmpDir, "canon4");
const work = makeWorkingRepo(tmpDir, "work4", remote);
const sha = git(work, ["rev-parse", "HEAD"]);
git(work, ["checkout", sha]);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.current_branch, null);
assert.equal(result.situation, "detached_head");
assert.match(result.manual_command, /git fetch origin/);
assert.doesNotMatch(result.manual_command, /git pull/);
});
});
describe("getUpdatesStatus — no remotes configured", () => {
it("returns a soft no-remotes payload", async () => {
const repo = path.join(tmpDir, "noremote");
fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { stdio: "ignore" });
fs.writeFileSync(path.join(repo, "README.md"), "lonely\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
const result = await getUpdatesStatus(repo, { skipFetch: true });
assert.equal(result.git_repo, true);
assert.equal(result.update_available, false);
assert.match(result.message, /No git remotes configured/);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* @file Tests for dashboard self-update HTTP endpoints.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `dashboard-updates-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function httpFetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (options.body) req.write(options.body);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
const addr = server.address();
BASE = `http://127.0.0.1:${addr.port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.unlinkSync(TEST_DB);
fs.unlinkSync(`${TEST_DB}-wal`);
fs.unlinkSync(`${TEST_DB}-shm`);
} catch {
// ignore
}
});
describe("GET /api/updates/status", () => {
it("returns update check payload", async () => {
const res = await httpFetch("/api/updates/status");
assert.equal(res.status, 200);
assert.equal(typeof res.body.git_repo, "boolean");
assert.equal(typeof res.body.update_available, "boolean");
if (res.body.git_repo) {
assert.ok(typeof res.body.repo_root === "string");
}
});
});
describe("POST /api/updates/check", () => {
it("returns a fresh update status payload", async () => {
const res = await httpFetch("/api/updates/check", { method: "POST", body: "{}" });
assert.equal(res.status, 200);
assert.equal(typeof res.body.git_repo, "boolean");
assert.equal(typeof res.body.update_available, "boolean");
});
});
describe("removed POST /api/updates/apply", () => {
it("returns 404 because self-update has been removed", async () => {
const res = await httpFetch("/api/updates/apply", {
method: "POST",
body: "{}",
});
assert.equal(res.status, 404);
});
});
+611
View File
@@ -0,0 +1,611 @@
/**
* @file Tests for the universal webhook delivery layer: payload formatting per
* platform (slack/discord/teams/generic), HMAC signing, target CRUD with secret
* redaction, validation, the synchronous test probe, rule-scoped dispatch,
* disabled-target skipping, retry/backoff with delivery-log recording, and the
* clear-data wipe.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
const crypto = require("crypto");
// Test DB + fast retry tunables must be set BEFORE requiring server modules
// (server/lib/webhooks.js reads the WEBHOOK_* env at module load).
const TEST_DB = path.join(os.tmpdir(), `dashboard-webhooks-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.WEBHOOK_MAX_ATTEMPTS = "2";
process.env.WEBHOOK_RETRY_BASE_MS = "10";
process.env.WEBHOOK_TIMEOUT_MS = "3000";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const webhooks = require("../lib/webhooks");
const providers = require("../lib/webhook-providers");
let server;
let BASE;
// Mock receiver — records every inbound request; behavior is tunable per-test.
const received = [];
let nextStatus = 200;
let nextBody = "ok"; // response body the mock returns (for body-veto tests)
let failTimes = 0; // respond 500 this many times before honoring nextStatus
let recvServer;
let RECV_URL;
function resetReceiver() {
received.length = 0;
nextStatus = 200;
nextBody = "ok";
failTimes = 0;
}
// Wipe all targets/deliveries between tests so an enabled target from one test
// never receives another test's dispatch (which would also fire real requests
// at example.com URLs from the CRUD tests).
beforeEach(() => {
db.prepare("DELETE FROM webhook_deliveries").run();
db.prepare("DELETE FROM webhook_targets").run();
webhooks.invalidateWebhookCache();
resetReceiver();
});
function fetchJson(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const post = (p, body) => fetchJson(p, { method: "POST", body });
const patch = (p, body) => fetchJson(p, { method: "PATCH", body });
const del = (p) => fetchJson(p, { method: "DELETE" });
const SAMPLE_ALERT = {
id: 42,
rule_id: "rule-abc",
rule_name: "Too many errors",
rule_type: "event_pattern",
session_id: "sess-1",
agent_id: "agent-1",
message: "5 matching events in 2 min (threshold 5)",
details: JSON.stringify({ observed_count: 5 }),
triggered_at: "2026-06-10T12:00:00.000Z",
};
before(async () => {
recvServer = http.createServer((req, res) => {
let b = "";
req.on("data", (c) => (b += c));
req.on("end", () => {
let parsed;
try {
parsed = JSON.parse(b);
} catch {
parsed = b;
}
received.push({ method: req.method, headers: req.headers, body: parsed });
let status = nextStatus;
if (failTimes > 0) {
failTimes -= 1;
status = 500;
}
res.statusCode = status;
res.end(nextBody);
});
});
await new Promise((r) => recvServer.listen(0, "127.0.0.1", r));
RECV_URL = `http://127.0.0.1:${recvServer.address().port}/hook`;
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (recvServer) recvServer.close();
try {
db.close();
} catch {
/* ignore */
}
});
describe("payload formatting", () => {
it("slack: header + section + context blocks with fallback text", () => {
const p = webhooks.formatPayload("slack", SAMPLE_ALERT);
assert.ok(p.text.includes("Too many errors"));
assert.equal(p.blocks[0].type, "header");
assert.equal(p.blocks[1].type, "section");
assert.equal(p.blocks[2].type, "context");
assert.ok(p.blocks[1].text.text.includes("threshold 5"));
});
it("discord: single rich embed with fields", () => {
const p = webhooks.formatPayload("discord", SAMPLE_ALERT);
assert.equal(p.embeds.length, 1);
assert.ok(p.embeds[0].title.includes("Too many errors"));
assert.equal(p.embeds[0].timestamp, SAMPLE_ALERT.triggered_at);
assert.ok(p.embeds[0].fields.some((f) => f.name === "Session"));
});
it("teams: Adaptive Card wrapped in the Workflows message envelope", () => {
const p = webhooks.formatPayload("teams", SAMPLE_ALERT);
assert.equal(p.type, "message");
assert.equal(p.attachments[0].contentType, "application/vnd.microsoft.card.adaptive");
const card = p.attachments[0].content;
assert.equal(card.type, "AdaptiveCard");
const factSet = card.body.find((b) => b.type === "FactSet");
assert.ok(factSet.facts.some((f) => f.title === "Type"));
});
it("generic: stable envelope with parsed details", () => {
const p = webhooks.formatPayload("generic", SAMPLE_ALERT);
assert.equal(p.event, "alert.triggered");
assert.equal(p.alert.rule_name, "Too many errors");
assert.deepEqual(p.alert.details, { observed_count: 5 });
});
it("generic: HMAC signature header when secret set", () => {
const target = { type: "generic", url: "https://x.test", secret: "s3cr3t" };
const { body, headers } = webhooks.buildRequest(target, SAMPLE_ALERT);
const ts = headers["X-Webhook-Timestamp"];
const expected =
"sha256=" + crypto.createHmac("sha256", "s3cr3t").update(`${ts}.${body}`).digest("hex");
assert.equal(headers["X-Webhook-Signature"], expected);
});
it("generic: custom headers cannot clobber Content-Type or signature", () => {
const target = {
type: "generic",
url: "https://x.test",
headers: { "Content-Type": "text/plain", "X-Webhook-Signature": "fake", "X-Custom": "ok" },
};
const { headers } = webhooks.buildRequest(target, SAMPLE_ALERT);
assert.equal(headers["Content-Type"], "application/json");
assert.equal(headers["X-Custom"], "ok");
assert.notEqual(headers["X-Webhook-Signature"], "fake");
});
});
describe("target CRUD + redaction", () => {
it("creates a generic target and never returns the raw url/secret", async () => {
const res = await post("/api/webhooks", {
name: "My endpoint",
type: "generic",
url: "https://example.com/hook/SECRET-TOKEN-1234",
secret: "signing-secret",
headers: { Authorization: "Bearer abc" },
});
assert.equal(res.status, 201);
const t = res.body.target;
assert.equal(t.name, "My endpoint");
assert.equal(t.has_secret, true);
assert.ok(!("secret" in t));
assert.ok(!t.url_preview.includes("SECRET-TOKEN"));
assert.ok(t.url_preview.includes("example.com"));
assert.deepEqual(t.headers, { Authorization: "••••" }); // value masked
});
it("rejects an invalid url and a bad type", async () => {
const r1 = await post("/api/webhooks", { name: "x", type: "generic", url: "not a url" });
assert.equal(r1.status, 400);
const r2 = await post("/api/webhooks", {
name: "x",
type: "carrier-pigeon",
url: "https://x.io",
});
assert.equal(r2.status, 400);
});
it("requires https for slack/discord/teams", async () => {
const r = await post("/api/webhooks", {
name: "x",
type: "slack",
url: "http://insecure.test/x",
});
assert.equal(r.status, 400);
});
it("patches enabled without touching the url/secret", async () => {
const created = await post("/api/webhooks", {
name: "patch-me",
type: "generic",
url: "https://example.com/abc",
secret: "keep-me",
});
const id = created.body.target.id;
const res = await patch(`/api/webhooks/${id}`, { enabled: false });
assert.equal(res.status, 200);
assert.equal(res.body.target.enabled, false);
assert.equal(res.body.target.has_secret, true); // secret preserved
// raw row still has the secret
assert.equal(stmts.getWebhookTarget.get(id).secret, "keep-me");
});
it("deletes a target", async () => {
const created = await post("/api/webhooks", {
name: "delete-me",
type: "generic",
url: "https://example.com/del",
});
const id = created.body.target.id;
const res = await del(`/api/webhooks/${id}`);
assert.equal(res.status, 200);
assert.equal(stmts.getWebhookTarget.get(id), undefined);
});
});
describe("delivery", () => {
it("dispatches a fired alert to an enabled target with the generic payload", async () => {
resetReceiver();
const created = await post("/api/webhooks", {
name: "live",
type: "generic",
url: RECV_URL,
});
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(received.length, 1);
assert.equal(received[0].body.event, "alert.triggered");
assert.equal(received[0].body.alert.rule_name, "Too many errors");
// a success delivery row was recorded
const last = stmts.lastWebhookDeliveryForTarget.get(created.body.target.id);
assert.equal(last.status, "success");
});
it("skips disabled targets", async () => {
resetReceiver();
const created = await post("/api/webhooks", {
name: "off",
type: "generic",
url: RECV_URL,
enabled: false,
});
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(received.length, 0);
assert.equal(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id), undefined);
});
it("honors rule_ids scoping", async () => {
resetReceiver();
await post("/api/webhooks", {
name: "scoped",
type: "generic",
url: RECV_URL,
rule_ids: ["some-other-rule"],
});
await webhooks.dispatchAlert(SAMPLE_ALERT); // rule_id "rule-abc" not in scope
assert.equal(received.length, 0);
});
it("retries on 5xx then records success", async () => {
resetReceiver();
failTimes = 1; // first attempt 500, second 200
const created = await post("/api/webhooks", {
name: "retry",
type: "generic",
url: RECV_URL,
});
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(received.length, 2); // one failed + one retried
const last = stmts.lastWebhookDeliveryForTarget.get(created.body.target.id);
assert.equal(last.status, "success");
assert.equal(last.attempts, 2);
});
it("records a failure when all attempts return 5xx", async () => {
resetReceiver();
nextStatus = 500;
failTimes = 0;
const created = await post("/api/webhooks", {
name: "fail",
type: "generic",
url: RECV_URL,
});
const settled = await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(settled[0].value.ok, false);
const last = stmts.lastWebhookDeliveryForTarget.get(created.body.target.id);
assert.equal(last.status, "failed");
assert.equal(last.status_code, 500);
});
it("does not retry on 4xx", async () => {
resetReceiver();
nextStatus = 400;
const created = await post("/api/webhooks", {
name: "badreq",
type: "generic",
url: RECV_URL,
});
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.equal(received.length, 1); // no retry
assert.equal(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id).status, "failed");
});
});
describe("test probe + clear-data", () => {
it("POST /:id/test delivers a synthetic alert and reports ok", async () => {
resetReceiver();
const created = await post("/api/webhooks", {
name: "probe",
type: "generic",
url: RECV_URL,
});
const res = await post(`/api/webhooks/${created.body.target.id}/test`);
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(received.length, 1);
assert.equal(received[0].body.alert.rule_type, "test");
});
it("clear-data wipes the delivery log but keeps targets", async () => {
resetReceiver();
const created = await post("/api/webhooks", { name: "keep", type: "generic", url: RECV_URL });
await webhooks.dispatchAlert(SAMPLE_ALERT);
assert.ok(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id));
await post("/api/settings/clear-data");
assert.equal(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id), undefined);
assert.ok(stmts.getWebhookTarget.get(created.body.target.id)); // target survives
});
});
describe("provider registry", () => {
it("exposes 14 first-class providers (+ generic = 15 types)", () => {
const firstClass = [
"slack",
"discord",
"teams",
"google_chat",
"mattermost",
"rocketchat",
"telegram",
"pagerduty",
"opsgenie",
"splunk_oncall",
"zapier",
"make",
"n8n",
"pipedream",
];
assert.equal(firstClass.length, 14);
for (const t of firstClass) {
assert.ok(providers.WEBHOOK_TYPES.includes(t), `${t} missing`);
}
assert.ok(providers.WEBHOOK_TYPES.includes("generic"));
assert.equal(providers.WEBHOOK_TYPES.length, 15);
});
it("GET /api/webhooks/providers returns redacted metadata", async () => {
const res = await fetchJson("/api/webhooks/providers");
assert.equal(res.status, 200);
const pd = res.body.providers.find((p) => p.type === "pagerduty");
assert.equal(pd.url_required, false); // has default URL
assert.ok(pd.fields.find((f) => f.key === "routing_key" && f.secret));
const slack = res.body.providers.find((p) => p.type === "slack");
assert.equal(slack.url_required, true);
});
});
describe("provider payload formatting", () => {
it("mattermost: Slack-style attachments", () => {
const p = providers.formatPayload("mattermost", SAMPLE_ALERT);
assert.ok(Array.isArray(p.attachments));
assert.ok(p.attachments[0].fields.some((f) => f.title === "Type"));
});
it("rocketchat: text + attachments", () => {
const p = providers.formatPayload("rocketchat", SAMPLE_ALERT);
assert.ok(p.text.includes("Too many errors"));
assert.ok(Array.isArray(p.attachments));
});
it("google_chat: simple text message", () => {
const p = providers.formatPayload("google_chat", SAMPLE_ALERT);
assert.ok(typeof p.text === "string" && p.text.includes("Too many errors"));
});
it("telegram: sendMessage shape with chat_id + HTML escaping", () => {
const p = providers.formatPayload(
"telegram",
{ ...SAMPLE_ALERT, rule_name: "a<b>c" },
{ chat_id: "123" }
);
assert.equal(p.chat_id, "123");
assert.equal(p.parse_mode, "HTML");
assert.ok(p.text.includes("a&lt;b&gt;c")); // escaped
});
it("pagerduty: Events API v2 with routing_key, severity, dedup_key", () => {
const p = providers.formatPayload("pagerduty", SAMPLE_ALERT, {
routing_key: "RK",
severity: "critical",
});
assert.equal(p.routing_key, "RK");
assert.equal(p.event_action, "trigger");
assert.equal(p.payload.severity, "critical");
assert.ok(p.dedup_key.includes(SAMPLE_ALERT.rule_id));
});
it("opsgenie: message + alias; api_key goes in the GenieKey header, not body", () => {
const p = providers.formatPayload("opsgenie", SAMPLE_ALERT, { api_key: "KEY" });
assert.ok(p.message.includes("Too many errors"));
assert.ok(!JSON.stringify(p).includes("KEY")); // key not in body
const headers = providers.resolveAuthHeaders({ type: "opsgenie", config: { api_key: "KEY" } });
assert.equal(headers.Authorization, "GenieKey KEY");
});
it("splunk_oncall: VictorOps message_type + entity", () => {
const p = providers.formatPayload("splunk_oncall", SAMPLE_ALERT, { severity: "CRITICAL" });
assert.equal(p.message_type, "CRITICAL");
assert.ok(p.entity_id.includes(SAMPLE_ALERT.rule_id));
});
it("generic family (zapier) uses the JSON envelope", () => {
const p = providers.formatPayload("zapier", SAMPLE_ALERT);
assert.equal(p.event, "alert.triggered");
});
});
describe("URL resolution", () => {
it("telegram derives its URL from the bot token", () => {
const url = providers.resolveUrl({
type: "telegram",
config: { bot_token: "TOK", chat_id: "1" },
});
assert.equal(url, "https://api.telegram.org/botTOK/sendMessage");
});
it("opsgenie picks the EU host when region=eu", () => {
assert.ok(
providers
.resolveUrl({ type: "opsgenie", config: { region: "eu" } })
.includes("api.eu.opsgenie.com")
);
assert.ok(
providers
.resolveUrl({ type: "opsgenie", config: { region: "us" } })
.includes("api.opsgenie.com")
);
});
it("pagerduty defaults to the Events API URL", () => {
assert.equal(
providers.resolveUrl({ type: "pagerduty", config: {} }),
"https://events.pagerduty.com/v2/enqueue"
);
});
});
describe("Splunk On-Call response-body veto", () => {
it("verifyResponse flags result=failure, trusts everything else", () => {
const vr = providers.PROVIDERS.splunk_oncall.verifyResponse;
assert.equal(vr('{"result":"failure","message":"bad routing key"}').ok, false);
assert.equal(vr('{"result":"success","entity_id":"x"}').ok, true);
assert.equal(vr("").ok, true); // empty body trusted
assert.equal(vr("not json").ok, true); // non-JSON 200 trusted
});
it("deliver() records a 200-with-result:failure as failed (no retry)", async () => {
resetReceiver();
nextStatus = 200;
nextBody = JSON.stringify({ result: "failure", message: "bad routing key" });
// Insert directly (splunk is https-only, can't go through the http-mock route).
const id = "splunk-veto-test";
stmts.insertWebhookTarget.run(
id,
"splunk",
"splunk_oncall",
RECV_URL,
1,
null,
null,
null,
JSON.stringify({ severity: "WARNING" })
);
webhooks.invalidateWebhookCache();
const target = webhooks.normalizeTarget(stmts.getWebhookTarget.get(id));
const res = await webhooks.deliver(target, SAMPLE_ALERT);
assert.equal(res.ok, false);
assert.match(res.error, /failure|bad routing key/i);
assert.equal(received.length, 1); // no retry on a logical rejection
assert.equal(stmts.lastWebhookDeliveryForTarget.get(id).status, "failed");
});
});
describe("provider CRUD + config redaction", () => {
it("creates a telegram target without a URL and redacts the bot token", async () => {
const res = await post("/api/webhooks", {
name: "tg",
type: "telegram",
config: { bot_token: "12345:SECRETTOKEN", chat_id: "999" },
});
assert.equal(res.status, 201);
const t = res.body.target;
assert.equal(t.config.bot_token, "••••"); // redacted
assert.equal(t.config.chat_id, "999"); // shown
assert.ok(!JSON.stringify(t).includes("SECRETTOKEN"));
assert.ok(t.url_preview.includes("api.telegram.org"));
});
it("requires routing_key for pagerduty", async () => {
const res = await post("/api/webhooks", { name: "pd", type: "pagerduty", config: {} });
assert.equal(res.status, 400);
});
it("rejects an unknown severity enum", async () => {
const res = await post("/api/webhooks", {
name: "pd2",
type: "pagerduty",
config: { routing_key: "RK", severity: "nope" },
});
assert.equal(res.status, 400);
});
it("patches opsgenie region without re-sending the api_key", async () => {
const created = await post("/api/webhooks", {
name: "og",
type: "opsgenie",
config: { api_key: "AAA", region: "us" },
});
const id = created.body.target.id;
const res = await patch(`/api/webhooks/${id}`, { config: { region: "eu" } });
assert.equal(res.status, 200);
assert.equal(res.body.target.config.region, "eu");
assert.equal(
stmts.getWebhookTarget.get(id) && JSON.parse(stmts.getWebhookTarget.get(id).config).api_key,
"AAA"
);
});
// pagerduty/opsgenie endpoints are https-only and (opsgenie) derive their own
// URL, so they can't point at the http test mock — assert the built request
// (URL + headers + body) directly instead.
it("buildRequest: pagerduty hits the Events API with the routing key in the body", () => {
const req = webhooks.buildRequest(
{ type: "pagerduty", config: { routing_key: "RK123", severity: "error" } },
SAMPLE_ALERT
);
assert.equal(req.url, "https://events.pagerduty.com/v2/enqueue");
const body = JSON.parse(req.body);
assert.equal(body.routing_key, "RK123");
assert.equal(body.payload.severity, "error");
});
it("buildRequest: opsgenie targets the region host and sets the GenieKey header", () => {
const req = webhooks.buildRequest(
{ type: "opsgenie", config: { api_key: "KEY9", region: "eu" } },
SAMPLE_ALERT
);
assert.ok(req.url.includes("api.eu.opsgenie.com"));
assert.equal(req.headers.Authorization, "GenieKey KEY9");
assert.ok(!req.body.includes("KEY9")); // key only in the header
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* @file Integration test for the Workflow-tool run feature (issue #167): a Stop
* hook with a transcript_path triggers on-disk journal ingestion off the
* response path, and the run then surfaces via GET /api/workflows/runs,
* GET /api/workflows/runs/:runId, and the session-detail `workflows[]` field.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `dashboard-wfapi-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
let ROOT;
const SESSION_ID = "wfapi-sess-1";
function fetchJson(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
},
(res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
try {
resolve({ status: res.statusCode, body: body ? JSON.parse(body) : null });
} catch {
resolve({ status: res.statusCode, body });
}
});
}
);
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
before(async () => {
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "wfapi-fixture-"));
const transcriptPath = path.join(ROOT, `${SESSION_ID}.jsonl`);
fs.writeFileSync(transcriptPath, "");
// On-disk run journal next to the transcript.
const wfDir = path.join(ROOT, SESSION_ID, "workflows");
fs.mkdirSync(wfDir, { recursive: true });
fs.writeFileSync(
path.join(wfDir, "wf_apitest1.json"),
JSON.stringify({
runId: "wf_apitest1",
workflowName: "api-review",
status: "completed",
startTime: 1700001000000,
durationMs: 4000,
defaultModel: "claude-opus-4-8",
agentCount: 1,
totalTokens: 999,
totalToolCalls: 2,
phases: [{ title: "Scan" }],
workflowProgress: [
{ type: "workflow_phase", index: 1, title: "Scan" },
{
type: "workflow_agent",
index: 1,
agentId: "x1",
state: "done",
phaseTitle: "Scan",
label: "scan:repo",
tokens: 999,
toolCalls: 2,
},
],
})
);
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
// Drive a Stop hook with the transcript_path — this is what triggers the
// post-response workflow ingest in the hooks router.
await fetchJson("/api/hooks/event", {
method: "POST",
body: {
hook_type: "Stop",
data: {
session_id: SESSION_ID,
cwd: "/tmp/proj",
transcript_path: transcriptPath,
},
},
});
// Ingest is fire-and-forget after res.json; poll until it lands.
for (let i = 0; i < 40; i++) {
const r = await fetchJson(`/api/workflows/runs?session_id=${SESSION_ID}`);
if (r.body && r.body.runs && r.body.runs.length > 0) break;
await sleep(50);
}
});
after(() => {
server?.close();
try {
db.close();
} catch {
/* ignore */
}
try {
fs.rmSync(ROOT, { recursive: true, force: true });
} catch {
/* ignore */
}
try {
fs.rmSync(TEST_DB, { force: true });
} catch {
/* ignore */
}
});
describe("GET /api/workflows/runs", () => {
it("lists the ingested run with hydrated phases/progress arrays", async () => {
const r = await fetchJson(`/api/workflows/runs?session_id=${SESSION_ID}`);
assert.equal(r.status, 200);
assert.ok(Array.isArray(r.body.runs));
const run = r.body.runs.find((x) => x.run_id === "wf_apitest1");
assert.ok(run, "run present");
assert.equal(run.name, "api-review");
assert.equal(run.status, "completed");
assert.equal(run.total_tokens, 999);
assert.ok(Array.isArray(run.phases) && run.phases.length === 1);
assert.ok(Array.isArray(run.progress) && run.progress.length === 2);
assert.equal(run.progress.filter((p) => p.type === "workflow_agent").length, 1);
assert.equal(typeof r.body.total, "number");
assert.ok(r.body.counts && typeof r.body.counts === "object");
assert.ok(r.body.counts.completed >= 1, "status counts include completed");
});
it("filters by status", async () => {
const completed = await fetchJson("/api/workflows/runs?status=completed");
assert.equal(completed.status, 200);
assert.ok(completed.body.runs.some((x) => x.run_id === "wf_apitest1"));
const running = await fetchJson("/api/workflows/runs?status=running");
assert.equal(running.status, 200);
assert.ok(
!running.body.runs.some((x) => x.run_id === "wf_apitest1"),
"completed run excluded from the running filter"
);
});
});
describe("GET /api/workflows/runs/:runId", () => {
it("returns the run with its linked inner agents", async () => {
const r = await fetchJson("/api/workflows/runs/wf_apitest1");
assert.equal(r.status, 200);
assert.equal(r.body.workflow.run_id, "wf_apitest1");
assert.ok(Array.isArray(r.body.agents));
const linked = r.body.agents.find((a) => a.id === `${SESSION_ID}-jsonl-x1`);
assert.ok(linked, "inner agent linked");
assert.equal(linked.workflow_run_id, "wf_apitest1");
assert.equal(linked.workflow_phase, "Scan");
});
it("404s an unknown run id", async () => {
const r = await fetchJson("/api/workflows/runs/wf_nope");
assert.equal(r.status, 404);
});
});
describe("GET /api/sessions/:id includes workflows[]", () => {
it("surfaces the run on the parent session detail", async () => {
const r = await fetchJson(`/api/sessions/${SESSION_ID}`);
assert.equal(r.status, 200);
assert.ok(Array.isArray(r.body.workflows));
assert.ok(r.body.workflows.some((w) => w.run_id === "wf_apitest1"));
});
});
+463
View File
@@ -0,0 +1,463 @@
/**
* @file Tests for Workflow-tool run ingestion (issue #167): parsing the on-disk
* run journal, upserting a workflows row, linking inner agents by the shared
* `${sessionId}-jsonl-<agentId>` id scheme, idempotency, runningcompleted
* detection with launch-time preservation, and folding inner-agent token usage
* into the session cost under a namespaced `workflow` service_tier.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
// Isolated test DB before requiring any server module.
const TEST_DB = path.join(os.tmpdir(), `dashboard-wf-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const dbModule = require("../db");
const { stmts } = dbModule;
const {
ingestWorkflowsForSession,
ingestAllWorkflows,
workflowsMaxMtime,
extractRunId,
nameFromScript,
mapState,
} = require("../lib/workflow-ingest");
const SESSION_ID = "sess-wf-1";
let ROOT; // temp transcript root
let transcriptPath;
function writeJson(p, obj) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(obj));
}
// A minimal subagent transcript with token usage + one tool call.
function agentJsonl(model, input, output) {
return [
{ type: "user", timestamp: "2026-02-01T00:00:00.000Z", message: { content: "go" } },
{
type: "assistant",
timestamp: "2026-02-01T00:00:02.000Z",
message: {
model,
content: [{ type: "tool_use", id: "t1", name: "WebSearch", input: {} }],
usage: {
input_tokens: input,
output_tokens: output,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
]
.map((l) => JSON.stringify(l))
.join("\n");
}
function subagentDir() {
return path.join(ROOT, SESSION_ID, "subagents");
}
function workflowsDir() {
return path.join(ROOT, SESSION_ID, "workflows");
}
before(() => {
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "wf-fixture-"));
transcriptPath = path.join(ROOT, `${SESSION_ID}.jsonl`);
fs.writeFileSync(transcriptPath, ""); // only dirname + basename are used
// Parent session + main agent (FK targets).
stmts.insertSession.run(
SESSION_ID,
"WF test session",
"active",
"/tmp/proj",
"claude-opus-4-8",
null
);
stmts.insertAgent.run(
`${SESSION_ID}-main`,
SESSION_ID,
"Main",
"main",
null,
"completed",
null,
null,
null
);
// A completed run journal with two inner agents in two phases.
writeJson(path.join(workflowsDir(), "wf_test123.json"), {
runId: "wf_test123",
taskId: "task-1",
workflowName: "review-changes",
status: "completed",
startTime: 1700000000000,
durationMs: 5000,
defaultModel: "claude-opus-4-8",
agentCount: 2,
totalTokens: 12345,
totalToolCalls: 7,
phases: [
{ title: "Review", detail: "review the diff" },
{ title: "Verify", detail: "verify findings" },
],
workflowProgress: [
{ type: "workflow_phase", index: 1, title: "Review" },
{ type: "workflow_phase", index: 2, title: "Verify" },
{
type: "workflow_agent",
index: 1,
agentId: "a1",
model: "claude-opus-4-8",
state: "done",
label: "review:bugs",
phaseTitle: "Review",
startedAt: 1700000000000,
tokens: 5000,
toolCalls: 3,
durationMs: 2000,
lastToolName: "Read",
},
{
type: "workflow_agent",
index: 2,
agentId: "a2",
model: "claude-haiku-4-5",
state: "error",
label: "verify:x",
phaseTitle: "Verify",
startedAt: 1700000002000,
tokens: 7345,
toolCalls: 4,
durationMs: 3000,
lastToolName: "Bash",
},
],
});
// Inner-agent transcripts in the per-run nested dir, each with token usage so
// ingest can fold their spend into the session cost.
const runAgentDir = path.join(workflowsDir(), "..", "subagents", "workflows", "wf_test123");
const agentLines = (model, input, output) =>
[
{ type: "user", timestamp: "2026-01-01T00:00:00.000Z", message: { content: "go" } },
{
type: "assistant",
timestamp: "2026-01-01T00:00:01.000Z",
message: {
model,
content: [{ type: "text", text: "done" }],
usage: {
input_tokens: input,
output_tokens: output,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
]
.map((l) => JSON.stringify(l))
.join("\n");
fs.mkdirSync(runAgentDir, { recursive: true });
fs.writeFileSync(
path.join(runAgentDir, "agent-a1.jsonl"),
agentLines("claude-opus-4-8", 4000, 1000)
);
fs.writeFileSync(
path.join(runAgentDir, "agent-a2.jsonl"),
agentLines("claude-opus-4-8", 6000, 1345)
);
});
after(() => {
try {
fs.rmSync(ROOT, { recursive: true, force: true });
} catch {
/* ignore */
}
try {
dbModule.db.close();
} catch {
/* ignore */
}
try {
fs.rmSync(TEST_DB, { force: true });
} catch {
/* ignore */
}
});
describe("extractRunId / nameFromScript", () => {
it("derives the same run id from a journal and its launch script", () => {
assert.equal(extractRunId("wf_run999.json"), "wf_run999");
assert.equal(extractRunId("/x/y/myflow-wf_run999.js"), "wf_run999");
});
it("strips the -wf_<runId> tail to recover the workflow name", () => {
assert.equal(nameFromScript("review-changes-wf_abc123.js"), "review-changes");
});
});
describe("mapState", () => {
it("maps journal states to agent statuses", () => {
assert.equal(mapState("done"), "completed");
assert.equal(mapState("completed"), "completed");
assert.equal(mapState("success"), "completed");
assert.equal(mapState("error"), "error");
assert.equal(mapState("failed"), "error");
assert.equal(mapState("running"), "working");
assert.equal(mapState("queued"), "working");
assert.equal(mapState("in_progress"), "working");
assert.equal(mapState("anything-unknown"), "completed");
assert.equal(mapState(null), "completed");
});
});
describe("ingestWorkflowsForSession — completed journal", () => {
it("ingests the journal as a workflow row with parsed phases/progress", async () => {
const changed = await ingestWorkflowsForSession(dbModule, {
id: SESSION_ID,
transcript_path: transcriptPath,
});
assert.ok(changed.length >= 1);
const wf = stmts.getWorkflow.get("wf_test123");
assert.ok(wf, "workflow row exists");
assert.equal(wf.session_id, SESSION_ID);
assert.equal(wf.name, "review-changes");
assert.equal(wf.status, "completed");
assert.equal(wf.agent_count, 2);
assert.equal(wf.total_tokens, 12345);
assert.equal(wf.total_tool_calls, 7);
assert.equal(wf.source, "journal");
assert.ok(wf.started_at, "started_at populated");
assert.equal(wf.ended_at, new Date(1700000000000 + 5000).toISOString());
assert.equal(JSON.parse(wf.phases).length, 2);
// progress keeps all entries (2 phase markers + 2 agents)
assert.equal(JSON.parse(wf.progress).length, 4);
assert.equal(JSON.parse(wf.progress).filter((p) => p.type === "workflow_agent").length, 2);
});
it("links each inner agent by the shared jsonl id scheme, with phase + status", () => {
const a1 = stmts.getAgent.get(`${SESSION_ID}-jsonl-a1`);
const a2 = stmts.getAgent.get(`${SESSION_ID}-jsonl-a2`);
assert.ok(a1 && a2, "both inner-agent rows exist");
assert.equal(a1.workflow_run_id, "wf_test123");
assert.equal(a1.workflow_phase, "Review");
assert.equal(a1.status, "completed");
assert.equal(a2.workflow_run_id, "wf_test123");
assert.equal(a2.workflow_phase, "Verify");
assert.equal(a2.status, "error");
const linked = stmts.listAgentsByWorkflow.all("wf_test123");
assert.equal(linked.length, 2);
});
it("folds inner-agent tokens into the session under a 'workflow' service_tier", () => {
const rows = dbModule.db
.prepare("SELECT * FROM token_usage WHERE session_id = ?")
.all(SESSION_ID);
assert.ok(rows.length > 0, "workflow token rows written");
assert.ok(
rows.every((r) => r.service_tier === "workflow"),
"isolated under the workflow tier (no collision with main buckets)"
);
// a1(4000)+a2(6000)=10000 input, 1000+1345=2345 output (same model → one row)
const totalInput = rows.reduce((s, r) => s + r.input_tokens + r.baseline_input, 0);
const totalOutput = rows.reduce((s, r) => s + r.output_tokens + r.baseline_output, 0);
assert.equal(totalInput, 10000);
assert.equal(totalOutput, 2345);
});
it("is idempotent — re-ingest creates no duplicate rows and stable token totals", async () => {
await ingestWorkflowsForSession(dbModule, { id: SESSION_ID, transcript_path: transcriptPath });
const wfCount = dbModule.db
.prepare("SELECT COUNT(*) AS n FROM workflows WHERE session_id = ?")
.get(SESSION_ID);
assert.equal(wfCount.n, 1);
const subCount = dbModule.db
.prepare("SELECT COUNT(*) AS n FROM agents WHERE session_id = ? AND type = 'subagent'")
.get(SESSION_ID);
assert.equal(subCount.n, 2);
// tokens not double-counted on re-ingest (replace semantics)
const tot = dbModule.db
.prepare(
"SELECT SUM(input_tokens + baseline_input) AS i FROM token_usage WHERE session_id = ?"
)
.get(SESSION_ID);
assert.equal(tot.i, 10000);
});
});
describe("running detection → completed transition", () => {
it("shows a launch-script-only run as running, then completes it preserving started_at", async () => {
// 1) Launch script, no journal yet.
fs.mkdirSync(path.join(workflowsDir(), "scripts"), { recursive: true });
fs.writeFileSync(path.join(workflowsDir(), "scripts", "deep-audit-wf_run999.js"), "// script");
await ingestWorkflowsForSession(dbModule, { id: SESSION_ID, transcript_path: transcriptPath });
const running = stmts.getWorkflow.get("wf_run999");
assert.ok(running, "running row created from launch script");
assert.equal(running.status, "running");
assert.equal(running.source, "live");
assert.equal(running.name, "deep-audit");
assert.ok(running.started_at, "running row has a launch time");
const launchTime = running.started_at;
// 2) Journal lands → same run_id → becomes completed, launch time preserved.
writeJson(path.join(workflowsDir(), "wf_run999.json"), {
runId: "wf_run999",
workflowName: "deep-audit",
status: "completed",
startTime: 1700000500000,
durationMs: 1000,
agentCount: 0,
totalTokens: 0,
totalToolCalls: 0,
phases: [],
workflowProgress: [],
});
await ingestWorkflowsForSession(dbModule, { id: SESSION_ID, transcript_path: transcriptPath });
const done = stmts.getWorkflow.get("wf_run999");
assert.equal(done.status, "completed");
assert.equal(done.started_at, launchTime, "launch time preserved across transition");
});
});
describe("workflowsMaxMtime", () => {
it("returns the newest artifact mtime for a session with workflows, 0 otherwise", () => {
assert.ok(workflowsMaxMtime(transcriptPath) > 0, "fingerprint > 0 when journals exist");
assert.equal(
workflowsMaxMtime(path.join(ROOT, "no-such-session.jsonl")),
0,
"0 when there are no workflow artifacts"
);
});
// Regression guard for the maintenance sweep (server/index.js step 3) and
// startWorkflowPoll: both skip a session whose workflow artifacts are
// unchanged and re-ingest only once the fingerprint advances. Before the
// gate, the 5-min sweep full-re-parsed every workflow journal and every
// inner agent-*.jsonl for every active session every cycle; on a large
// corpus each sweep outran the interval, sweeps overlapped, and the event
// loop pegged (dashboard stopped responding). This asserts the exact
// skip/re-ingest decision that gate relies on. Isolated root — no shared
// fixture state so later suites are unaffected.
it("gates skip vs re-ingest: stable fingerprint when unchanged, higher after a new artifact", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
try {
const sid = "sess-gate";
const tp = path.join(root, `${sid}.jsonl`);
fs.writeFileSync(tp, "");
const wdir = path.join(root, sid, "workflows");
writeJson(path.join(wdir, "wf_a.json"), {
runId: "wf_a",
status: "completed",
startTime: 1700000000000,
workflowProgress: [],
});
const seen = new Map(); // mirrors sweepWorkflowSeen / lastSeen
const m1 = workflowsMaxMtime(tp);
assert.ok(m1 > 0, "fingerprint > 0 with a journal");
assert.equal(m1 === 0 || seen.get(sid) === m1, false, "first sight ingests");
seen.set(sid, m1);
const m2 = workflowsMaxMtime(tp);
assert.equal(m2, m1, "fingerprint stable when nothing changes");
assert.equal(m2 === 0 || seen.get(sid) === m2, true, "unchanged session is skipped");
const later = path.join(wdir, "wf_b.json");
writeJson(later, {
runId: "wf_b",
status: "completed",
startTime: 1700000001000,
workflowProgress: [],
});
const bump = m1 / 1000 + 5; // seconds, safely newer than m1
fs.utimesSync(later, bump, bump);
const m3 = workflowsMaxMtime(tp);
assert.ok(m3 > m1, "fingerprint advances when a new artifact appears");
assert.equal(m3 === 0 || seen.get(sid) === m3, false, "changed session is re-ingested");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
describe("live running workflow (no terminal journal)", () => {
it("builds real-time progress + tokens from the streaming run dir", async () => {
const runId = "wf_live77";
const runDir = path.join(ROOT, SESSION_ID, "subagents", "workflows", runId);
fs.mkdirSync(runDir, { recursive: true });
// a1 finished (has a result event), a2 still running (started only)
fs.writeFileSync(path.join(runDir, "agent-a1.jsonl"), agentJsonl("claude-opus-4-8", 3000, 800));
fs.writeFileSync(path.join(runDir, "agent-a2.jsonl"), agentJsonl("claude-opus-4-8", 1500, 200));
fs.writeFileSync(
path.join(runDir, "journal.jsonl"),
[
JSON.stringify({ type: "started", agentId: "a1" }),
JSON.stringify({ type: "started", agentId: "a2" }),
// a3 started but has no transcript yet (queued) → minimal live entry
JSON.stringify({ type: "started", agentId: "a3" }),
JSON.stringify({ type: "result", agentId: "a1", result: { ok: true, note: "done" } }),
].join("\n")
);
// a launch script (no terminal journal) → name resolves from it
fs.mkdirSync(path.join(workflowsDir(), "scripts"), { recursive: true });
fs.writeFileSync(path.join(workflowsDir(), "scripts", `ds-pipeline-${runId}.js`), "// s");
await ingestWorkflowsForSession(dbModule, { id: SESSION_ID, transcript_path: transcriptPath });
const wf = stmts.getWorkflow.get(runId);
assert.ok(wf, "live run row created");
assert.equal(wf.status, "running", "shown as running before terminal journal");
assert.equal(wf.source, "live");
assert.equal(wf.name, "ds-pipeline");
assert.equal(wf.agent_count, 3, "two transcripts + one queued agent");
assert.ok(wf.total_tokens > 0, "live tokens accumulated");
assert.ok(wf.total_tool_calls >= 2, "live tool calls counted");
const prog = JSON.parse(wf.progress);
assert.equal(prog.length, 3);
const a1 = prog.find((p) => p.agentId === "a1");
const a2 = prog.find((p) => p.agentId === "a2");
const a3 = prog.find((p) => p.agentId === "a3");
assert.equal(a1.state, "done", "a1 has a result → done");
assert.equal(a2.state, "running", "a2 only started → running");
assert.equal(a3.state, "running", "a3 queued (started, no transcript) → running");
assert.equal(a3.tokens, 0, "queued agent has no tokens yet");
assert.ok(a1.tokens > 0 && a1.toolCalls >= 1);
assert.ok(a1.resultPreview, "finished agent carries its result");
// inner agents linked + live workflow tokens folded under the workflow tier
assert.equal(stmts.listAgentsByWorkflow.all(runId).length, 2);
const liveTier = dbModule.db
.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = ? AND service_tier = ?")
.get(SESSION_ID, "workflow");
assert.ok(liveTier.n >= 1, "workflow-tier cost row written for the live run");
});
});
describe("ingestAllWorkflows backfill", () => {
it("ingests on-disk workflows for sessions whose transcript_path is in the DB", async () => {
// Backfill resolves the transcript from the session row, so persist it.
dbModule.db
.prepare("UPDATE sessions SET transcript_path = ? WHERE id = ?")
.run(transcriptPath, SESSION_ID);
const res = await ingestAllWorkflows(dbModule);
assert.ok(res.sessions >= 1, "at least one session backfilled");
assert.ok(res.workflows >= 1, "at least one workflow ingested");
// The completed fixture run is present after backfill.
assert.ok(stmts.getWorkflow.get("wf_test123"), "fixture run present");
});
});
@@ -0,0 +1,293 @@
/**
* @file Tests for dual-layout (flat + nested Workflow-tool) sub-agent transcript
* resolution. Covers the resolver helpers in server/lib/claude-home.js, the
* durable snapshot writer in scripts/import-history.js, and the end-to-end
* GET /:id/transcript?run_id= route that surfaces a workflow inner agent's full
* (un-truncated) prompt/result text in the Workflows UI. Uses Node's built-in
* test runner with temp CLAUDE_HOME / DASHBOARD_DATA_DIR roots.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
// Isolate the dashboard DB and Claude Code home BEFORE requiring server modules.
const STAMP = `wf-transcript-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
const CLAUDE_HOME = path.join(TMP, "home");
const DATA_DIR = path.join(TMP, "data");
const TEST_DB = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.CLAUDE_HOME = CLAUDE_HOME;
process.env.DASHBOARD_DATA_DIR = DATA_DIR;
const {
resolveAgentTranscriptInDir,
getSubagentTranscriptPath,
findSubagentTranscriptPath,
getSnapshotSubagentTranscriptPath,
} = require("../lib/claude-home");
const {
snapshotTranscript,
findSessionWorkflowSubagents,
} = require("../../scripts/import-history");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
// encodeCwd is not exported; mirror its rule for building project paths.
const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
const CWD = "/tmp/cam-wf-project";
const SESSION = "sess-wf-resolve";
const SESSION_SNAP = "sess-wf-snapshot";
const RUN1 = "wf_run1aaaaaa";
const RUN2 = "wf_run2bbbbbb";
const WF_ID = "ad18a79192af10ed1"; // workflow inner agent — nested only
const FLAT_ID = "bd29b80203bf21fe2"; // regular sub-agent — flat
const DUP_ID = "cc1122334455667788"; // present in BOTH runs — ambiguous
const PROJECTS = path.join(CLAUDE_HOME, "projects");
const ENC_DIR = path.join(PROJECTS, enc(CWD));
const SUBAGENTS = path.join(ENC_DIR, SESSION, "subagents");
// A result longer than the journal's truncated preview would ever carry, so the
// route test can prove the full text (not a "…"-suffixed teaser) comes back.
const LONG_RESULT =
"CONFIRMED: " +
"the orbital telemetry reconciles across all three independent ground stations. ".repeat(8);
const PROMPT_TEXT = "Adversarially verify the starship claim against primary sources.";
function writeFile(p, contents) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, contents);
}
function jsonl(lines) {
return lines.map((o) => JSON.stringify(o)).join("\n") + "\n";
}
function transcriptJsonl(prompt, result) {
return jsonl([
{ type: "user", message: { role: "user", content: [{ type: "text", text: prompt }] } },
{
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: result }] },
},
]);
}
function fetch(urlPath) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{ hostname: url.hostname, port: url.port, path: url.pathname + url.search, method: "GET" },
(res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
req.end();
});
}
function post(urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = JSON.stringify(body);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(b || "{}") }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
let server;
let BASE;
before(async () => {
// Live project tree: one flat sub-agent + nested workflow agents, with DUP_ID
// deliberately present in two runs to exercise glob ambiguity.
writeFile(path.join(SUBAGENTS, `agent-${FLAT_ID}.jsonl`), transcriptJsonl("flat", "flat-result"));
writeFile(
path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`),
transcriptJsonl(PROMPT_TEXT, LONG_RESULT)
);
writeFile(
path.join(SUBAGENTS, "workflows", RUN1, `agent-${DUP_ID}.jsonl`),
transcriptJsonl("dup-r1", "dup-r1-result")
);
writeFile(
path.join(SUBAGENTS, "workflows", RUN2, `agent-${DUP_ID}.jsonl`),
transcriptJsonl("dup-r2", "dup-r2-result")
);
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
try {
fs.rmSync(TMP, { recursive: true, force: true });
} catch {
/* ignore cleanup errors */
}
});
describe("resolveAgentTranscriptInDir", () => {
it("prefers the flat layout (regular sub-agents resolve unchanged)", () => {
const hit = resolveAgentTranscriptInDir(SUBAGENTS, FLAT_ID);
assert.equal(hit, path.join(SUBAGENTS, `agent-${FLAT_ID}.jsonl`));
});
it("resolves a nested Workflow agent directly when the runId is known", () => {
const hit = resolveAgentTranscriptInDir(SUBAGENTS, WF_ID, RUN1);
assert.equal(hit, path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`));
});
it("resolves a nested agent by scanning when it appears in exactly one run", () => {
const hit = resolveAgentTranscriptInDir(SUBAGENTS, WF_ID);
assert.equal(hit, path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`));
});
it("returns null for an agentId ambiguous across runs (no guessing)", () => {
assert.equal(resolveAgentTranscriptInDir(SUBAGENTS, DUP_ID), null);
});
it("disambiguates an across-run agentId once the runId is supplied", () => {
assert.equal(
resolveAgentTranscriptInDir(SUBAGENTS, DUP_ID, RUN2),
path.join(SUBAGENTS, "workflows", RUN2, `agent-${DUP_ID}.jsonl`)
);
});
it("never throws and returns null for missing inputs", () => {
assert.equal(resolveAgentTranscriptInDir(null, WF_ID), null);
assert.equal(resolveAgentTranscriptInDir(SUBAGENTS, "does-not-exist"), null);
assert.equal(resolveAgentTranscriptInDir(SUBAGENTS, WF_ID, "wf_nope"), null);
});
});
describe("getSubagentTranscriptPath / findSubagentTranscriptPath", () => {
it("resolves a nested Workflow agent via cwd + runId", () => {
const hit = getSubagentTranscriptPath(SESSION, CWD, WF_ID, RUN1);
assert.equal(hit, path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`));
});
it("still resolves a flat regular sub-agent (no runId — no regression)", () => {
const hit = getSubagentTranscriptPath(SESSION, CWD, FLAT_ID);
assert.equal(hit, path.join(SUBAGENTS, `agent-${FLAT_ID}.jsonl`));
});
it("falls back to a project scan (cwd unknown) for the nested layout", () => {
const hit = findSubagentTranscriptPath(SESSION, WF_ID, RUN1);
assert.equal(hit, path.join(SUBAGENTS, "workflows", RUN1, `agent-${WF_ID}.jsonl`));
});
it("returns null when nothing resolves", () => {
assert.equal(getSubagentTranscriptPath(SESSION, CWD, "missing", RUN1), null);
assert.equal(getSubagentTranscriptPath(SESSION, null, WF_ID, RUN1), null);
});
});
describe("snapshotTranscript (durable nested-layout fallback)", () => {
it("discovers nested Workflow sub-agents separately from flat ones", () => {
const mainPath = path.join(ENC_DIR, `${SESSION}.jsonl`);
writeFile(mainPath, jsonl([{ type: "summary", summary: "x" }]));
const found = findSessionWorkflowSubagents(mainPath);
const rels = found.map((f) => f.rel).sort();
assert.deepEqual(rels, [
path.join("workflows", RUN1, `agent-${WF_ID}.jsonl`),
path.join("workflows", RUN1, `agent-${DUP_ID}.jsonl`),
path.join("workflows", RUN2, `agent-${DUP_ID}.jsonl`),
]);
});
it("preserves the nested layout into the snapshot so the read route resolves it", () => {
// Build an independent source session, snapshot it, then resolve from the
// snapshot dir exactly as the route's third fallback does.
const srcMain = path.join(ENC_DIR, `${SESSION_SNAP}.jsonl`);
const srcNested = path.join(
ENC_DIR,
SESSION_SNAP,
"subagents",
"workflows",
RUN1,
`agent-${WF_ID}.jsonl`
);
writeFile(srcMain, jsonl([{ type: "summary", summary: "snap" }]));
writeFile(srcNested, transcriptJsonl(PROMPT_TEXT, LONG_RESULT));
snapshotTranscript(srcMain, SESSION_SNAP);
const snapHit = getSnapshotSubagentTranscriptPath(SESSION_SNAP, WF_ID, RUN1);
assert.ok(snapHit, "snapshot resolver should find the preserved nested transcript");
assert.equal(fs.readFileSync(snapHit, "utf8"), fs.readFileSync(srcNested, "utf8"));
});
it("does not throw for a session with no transcripts", () => {
assert.doesNotThrow(() => snapshotTranscript(path.join(ENC_DIR, "nope.jsonl"), "nope"));
});
});
describe("GET /:id/transcript?run_id= (full workflow agent text)", () => {
it("returns the full, un-truncated prompt + result for a nested agent", async () => {
const created = await post("/api/sessions", { id: SESSION, name: "wf", cwd: CWD });
assert.equal(created.status, 201);
const res = await fetch(
`/api/sessions/${SESSION}/transcript?agent_id=${WF_ID}&run_id=${RUN1}&limit=200`
);
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.messages));
const texts = res.body.messages
.flatMap((m) => m.content || [])
.filter((b) => b.type === "text")
.map((b) => b.text);
assert.ok(texts.includes(PROMPT_TEXT), "user prompt should be present in full");
assert.ok(texts.includes(LONG_RESULT), "assistant result should be present in full");
// The whole point of the fix: full text, not a "…"-suffixed teaser.
assert.ok(!texts.some((t) => t.endsWith("…")));
});
it("returns an empty message list (no throw) for an unknown run/agent", async () => {
const res = await fetch(
`/api/sessions/${SESSION}/transcript?agent_id=${WF_ID}&run_id=wf_missing&limit=200`
);
assert.equal(res.status, 200);
assert.deepEqual(res.body.messages, []);
});
});
+413
View File
@@ -0,0 +1,413 @@
/**
* @file Tests for server/lib/worktree.js against a REAL git repository created
* in a temp directory. Every behaviour worth testing here is git's own branch
* collisions, what `clean -fd` spares, what `worktree list` reports so mocking
* git would only test our idea of git.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, before, 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 { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-wt-"));
process.env.LANES_ROOT = path.join(ROOT, "lanes");
const wt = require("../lib/worktree");
const SRC = path.join(ROOT, "src-repo");
const g = (cwd, ...args) => {
// Scrub git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
// so the fixture builder doesn't inherit them from the test harness.
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_COMMON_DIR;
delete env.GIT_OBJECT_DIRECTORY;
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete env.GIT_PREFIX;
delete env.GIT_NAMESPACE;
delete env.GIT_CONFIG_PARAMETERS;
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
before(() => {
fs.mkdirSync(SRC, { recursive: true });
g(SRC, "init", "-b", "main");
g(SRC, "config", "user.email", "t@example.com");
g(SRC, "config", "user.name", "Test");
fs.writeFileSync(path.join(SRC, "README.md"), "hello\n");
fs.writeFileSync(path.join(SRC, ".gitignore"), "node_modules/\n.env\n");
g(SRC, "add", "-A");
g(SRC, "commit", "-m", "init");
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
function laneFor(dir, branch, over = {}) {
return {
id: 1,
kind: "managed",
cwd: dir,
branch,
source_repo: SRC,
base_branch: "main",
...over,
};
}
describe("worktree", () => {
it("slugifies a title into a safe single segment", () => {
assert.equal(wt.slugify("Rename Metric → Rule!"), "rename-metric-rule");
assert.equal(wt.slugify(" a//b "), "a-b");
assert.ok(wt.slugify("x".repeat(80)).length <= 40);
});
it("resolves the base branch, falling back when origin has none", async () => {
assert.equal(await wt.resolveBase(SRC, "main"), "main");
assert.equal(await wt.resolveBase(SRC, "does-not-exist"), "main");
});
it("creates a worktree on a new branch and lists it", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
const r = await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/alpha", base: "main" });
assert.equal(r.created, true);
assert.ok(fs.existsSync(path.join(dir, "README.md")));
const list = await wt.listWorktrees(SRC);
assert.ok(list.some((w) => w.path === dir && w.branch === "feat/alpha"));
});
it("refuses a branch already checked out in another worktree", async () => {
const dir2 = path.join(process.env.LANES_ROOT, "src-repo__alpha2");
await assert.rejects(
() => wt.addWorktree({ sourceRepo: SRC, dir: dir2, branch: "feat/alpha", base: "main" }),
(e) => e.code === "EBRANCHBUSY" && typeof e.checkedOutAt === "string"
);
});
it("counts dirty, untracked and unpushed work", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
fs.appendFileSync(path.join(dir, "README.md"), "edit\n");
fs.writeFileSync(path.join(dir, "scratch.txt"), "untracked\n");
fs.mkdirSync(path.join(dir, "node_modules"), { recursive: true });
fs.writeFileSync(path.join(dir, "node_modules", "dep.js"), "x\n");
const s = await wt.statusCounts(dir);
assert.equal(s.dirty, 1);
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
assert.match(s.head, /^[0-9a-f]{7,40}$/);
// The fixture has no remotes. Given the lane's base branch, unpushedCount
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
assert.equal(await wt.unpushedCount(dir, "main"), 0);
// With no base to measure against (an adopted lane has none), it falls back
// to the total commit count, since every commit is then at risk.
assert.equal(await wt.unpushedCount(dir), 1);
});
it("reset restores base, drops untracked files, and spares gitignored ones", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
await wt.resetWorktree(laneFor(dir, "feat/alpha"));
assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hello\n");
assert.equal(fs.existsSync(path.join(dir, "scratch.txt")), false);
assert.equal(fs.existsSync(path.join(dir, "node_modules", "dep.js")), true);
const s = await wt.statusCounts(dir);
assert.equal(s.dirty, 0);
// Verify we're on the feature branch, not left on base
const branch = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim();
assert.equal(branch, "feat/alpha");
});
it("reset against a bogus base_branch throws ENOBASE and leaves worktree untouched", async () => {
// Create a new worktree with a bogus base that will exist as a branch
// but we'll change it to non-existent in the lane
const dir = path.join(process.env.LANES_ROOT, "src-repo__bogus-test");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/bogus-test", base: "main" });
// Make a modification to detect if the worktree is mutated
fs.writeFileSync(path.join(dir, "test-file.txt"), "test\n");
// Get the current state before the failed reset
const branchBefore = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim();
const statusBefore = wt.statusCounts(dir);
// Try to reset against a bogus base_branch
await assert.rejects(
() =>
wt.resetWorktree(laneFor(dir, "feat/bogus-test", { base_branch: "non-existent-branch" })),
(e) => e.code === "ENOBASE" && e.message.includes("non-existent-branch")
);
// Verify the worktree was not mutated: still on the same branch
const branchAfter = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim();
assert.equal(branchAfter, branchBefore);
// Verify the file still exists (no mutations happened)
assert.ok(fs.existsSync(path.join(dir, "test-file.txt")));
// Clean up
await wt.removeWorktree(laneFor(dir, "feat/bogus-test"));
});
it("refuses to destroy an adopted lane, a path outside LANES_ROOT, or a non-worktree", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
await assert.rejects(
() => wt.assertDestroyable(laneFor(dir, "feat/alpha", { kind: "adopted" })),
(e) => e.code === "ENOTMANAGED"
);
await assert.rejects(
() => wt.assertDestroyable(laneFor("/tmp", "feat/alpha")),
(e) => e.code === "EOUTSIDEROOT"
);
const ghost = path.join(process.env.LANES_ROOT, "src-repo__ghost");
fs.mkdirSync(ghost, { recursive: true });
await assert.rejects(
() => wt.assertDestroyable(laneFor(ghost, "feat/ghost")),
(e) => e.code === "ENOTWORKTREE"
);
});
// These call removeWorktree DIRECTLY. The route short-circuits adopted lanes
// before reaching it, so route-level tests can never pin its guard: deleting
// `await assertDestroyable(lane)` from removeWorktree left all 844 server tests
// green. Each case asserts the error code AND that nothing was destroyed.
describe("removeWorktree refuses what it must not destroy", () => {
it("refuses an adopted lane and leaves its directory and files alone", async () => {
const dir = path.join(ROOT, "adopted-project");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "real-work.txt");
fs.writeFileSync(file, "the user's own project\n");
await assert.rejects(
() => wt.removeWorktree(laneFor(dir, "feat/whatever", { kind: "adopted" })),
(e) => e.code === "ENOTMANAGED"
);
assert.equal(fs.existsSync(dir), true);
assert.equal(fs.readFileSync(file, "utf8"), "the user's own project\n");
});
it("refuses a cwd outside LANES_ROOT and leaves that directory alone", async () => {
const dir = path.join(ROOT, "outside-root");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "keep.txt");
fs.writeFileSync(file, "outside the sandbox\n");
await assert.rejects(
() => wt.removeWorktree(laneFor(dir, "feat/outside")),
(e) => e.code === "EOUTSIDEROOT"
);
assert.equal(fs.existsSync(dir), true);
assert.equal(fs.readFileSync(file, "utf8"), "outside the sandbox\n");
});
it("refuses a real directory inside LANES_ROOT that git does not list as a worktree", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__not-a-worktree");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "keep.txt");
fs.writeFileSync(file, "never registered with git\n");
await assert.rejects(
() => wt.removeWorktree(laneFor(dir, "feat/not-a-worktree")),
(e) => e.code === "ENOTWORKTREE"
);
assert.equal(fs.existsSync(dir), true);
assert.equal(fs.readFileSync(file, "utf8"), "never registered with git\n");
});
});
it("removes a lane whose worktree was deleted by hand, pruning git's stale record", async () => {
// The design promises "the lane reports `missing` and only `remove` is
// offered, taking the prune path". Before this, check 2 mapped the vanished
// path to EOUTSIDEROOT and the lane could never be removed at all.
const dir = path.join(process.env.LANES_ROOT, "src-repo__hand-deleted");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/hand-deleted", base: "main" });
fs.rmSync(dir, { recursive: true, force: true });
assert.ok(
(await wt.listWorktrees(SRC)).some((w) => w.path === dir),
"git should still list the hand-deleted worktree"
);
await wt.removeWorktree(laneFor(dir, "feat/hand-deleted"));
assert.equal(
(await wt.listWorktrees(SRC)).some((w) => w.path === dir),
false
);
assert.equal(g(SRC, "branch", "--list", "feat/hand-deleted").trim(), "");
});
it("removes a lane git never knew about, when its directory is already gone", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__never-existed");
assert.equal(fs.existsSync(dir), false);
// No throw: there is nothing on disk and nothing in git to clean up.
await wt.removeWorktree(laneFor(dir, "feat/never-existed"));
});
it("still refuses the prune path for a missing cwd outside LANES_ROOT", async () => {
// The directory is gone, so check 2 cannot realpath it — but it must still
// hold, or a lane pointing anywhere could prune a source repo's worktrees.
await assert.rejects(
() => wt.removeWorktree(laneFor(path.join(ROOT, "gone-and-outside"), "feat/gone")),
(e) => e.code === "EOUTSIDEROOT"
);
});
it("one prunable sibling worktree does not break reset or remove for other lanes", async () => {
// git keeps listing a hand-deleted worktree as `prunable`. realpathSync on
// every listed entry threw ENOENT out of assertDestroyable, so a single
// stale sibling produced an opaque 500 naming an unrelated directory for
// every managed lane in the same repo.
const victimDir = path.join(process.env.LANES_ROOT, "src-repo__prunable-victim");
await wt.addWorktree({ sourceRepo: SRC, dir: victimDir, branch: "feat/victim", base: "main" });
const siblingDir = path.join(process.env.LANES_ROOT, "src-repo__prunable-sibling");
await wt.addWorktree({
sourceRepo: SRC,
dir: siblingDir,
branch: "feat/sibling",
base: "main",
});
fs.rmSync(siblingDir, { recursive: true, force: true });
assert.ok(
(await wt.listWorktrees(SRC)).some((w) => w.path === siblingDir),
"git should still list the prunable sibling"
);
await wt.assertDestroyable(laneFor(victimDir, "feat/victim"));
await wt.resetWorktree(laneFor(victimDir, "feat/victim"));
await wt.removeWorktree(laneFor(victimDir, "feat/victim"));
assert.equal(fs.existsSync(victimDir), false);
// Clean up the stale record so later tests see a tidy list.
await wt.removeWorktree(laneFor(siblingDir, "feat/sibling"));
});
it("removes a lane whose worktree's .git pointer is corrupt (unreadable), deregistering it without touching its directory", async () => {
// `git worktree remove --force` (even --force --force) refuses outright
// when the worktree's OWN .git file fails git's validation — this is what
// `unreadable` in the preflight actually is. All three assertDestroyable
// checks still pass (the directory exists, is inside LANES_ROOT, and is
// still listed by the source repo), so removeWorktree must not get stuck.
const dir = path.join(process.env.LANES_ROOT, "src-repo__corrupt");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/corrupt", base: "main" });
fs.writeFileSync(path.join(dir, "keep.txt"), "still here\n");
fs.writeFileSync(path.join(dir, ".git"), "garbage\n");
assert.throws(
() => g(dir, "status"),
"sanity: git itself must refuse to operate inside the corrupt worktree"
);
await wt.removeWorktree(laneFor(dir, "feat/corrupt"));
assert.equal(
(await wt.listWorktrees(SRC)).some((w) => w.path === dir),
false,
"git should no longer list the corrupt worktree"
);
assert.equal(g(SRC, "branch", "--list", "feat/corrupt").trim(), "");
// The directory and its files were never touched.
assert.equal(fs.existsSync(dir), true);
assert.equal(fs.readFileSync(path.join(dir, "keep.txt"), "utf8"), "still here\n");
});
it("removes the worktree and its branch, leaving git's list clean", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
await wt.removeWorktree(laneFor(dir, "feat/alpha"));
assert.equal(fs.existsSync(dir), false);
const list = await wt.listWorktrees(SRC);
assert.equal(
list.some((w) => w.path === dir),
false
);
const branches = g(SRC, "branch", "--list", "feat/alpha").trim();
assert.equal(branches, "");
});
it("never deletes the base branch even if a lane claims it", async () => {
const dir = path.join(process.env.LANES_ROOT, "src-repo__beta");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/beta", base: "main" });
await wt.removeWorktree(laneFor(dir, "main")); // lane lies about its branch
assert.match(g(SRC, "branch", "--list", "main"), /main/);
});
it("scrubs git hook environment variables from child processes", async () => {
// Regression test: GIT_DIR, GIT_INDEX_FILE, etc. from git hooks must not
// corrupt git operations on worktrees (where .git is a file, not a directory).
// This test verifies that addWorktree and statusCounts work even when these
// variables are set to bogus values.
const oldGitDir = process.env.GIT_DIR;
const oldGitIndexFile = process.env.GIT_INDEX_FILE;
try {
process.env.GIT_DIR = "/nonexistent/.git";
process.env.GIT_INDEX_FILE = "/nonexistent/.git/index";
const dir = path.join(process.env.LANES_ROOT, "src-repo__scrub-test");
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/scrub-test", base: "main" });
const s = await wt.statusCounts(dir);
assert.ok(s.head); // should have a commit hash
assert.equal(s.dirty, 0); // should be clean
// Clean up
await wt.removeWorktree(laneFor(dir, "feat/scrub-test"));
} finally {
// Restore original values
if (oldGitDir !== undefined) {
process.env.GIT_DIR = oldGitDir;
} else {
delete process.env.GIT_DIR;
}
if (oldGitIndexFile !== undefined) {
process.env.GIT_INDEX_FILE = oldGitIndexFile;
} else {
delete process.env.GIT_INDEX_FILE;
}
}
});
});
describe("gitFacts", () => {
it("reports branch, short head, subject and working-tree counts", async () => {
const dir = path.join(ROOT, "facts-repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "feat/facts");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "tracked.txt"), "one\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "seed the facts fixture");
// one modified tracked file, one file git has never seen
fs.writeFileSync(path.join(dir, "tracked.txt"), "two\n");
fs.writeFileSync(path.join(dir, "brand-new.txt"), "x\n");
const facts = await wt.gitFacts(dir);
assert.equal(facts.branch, "feat/facts");
assert.equal(facts.head, g(dir, "rev-parse", "--short", "HEAD").trim());
assert.equal(facts.subject, "seed the facts fixture");
assert.equal(facts.dirty, 1);
assert.equal(facts.untracked, 1);
});
it("reports the literal HEAD git gives for a detached checkout", async () => {
const dir = path.join(ROOT, "facts-detached");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "main");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "only commit");
g(dir, "checkout", "--detach", "HEAD");
const facts = await wt.gitFacts(dir);
assert.equal(facts.branch, "HEAD");
assert.equal(facts.dirty, 0);
assert.equal(facts.untracked, 0);
});
it("rejects rather than reporting facts for a directory that is not a repo", async () => {
const dir = path.join(ROOT, "facts-plain");
fs.mkdirSync(dir, { recursive: true });
await assert.rejects(() => wt.gitFacts(dir));
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Compatibility wrapper around Node.js built-in node:sqlite (DatabaseSync).
* Provides a better-sqlite3-compatible API so the rest of the codebase
* works without the native module.
*
* Available on Node.js >= 22.5.0 (node:sqlite is experimental).
* On older Node versions, require() will throw and the caller should
* handle the error (e.g. show an informative message).
*
* @file This module exports a Database class that wraps node:sqlite's DatabaseSync to provide a better-sqlite3-like API.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { DatabaseSync } = require("node:sqlite");
class Database {
constructor(filePath) {
this._db = new DatabaseSync(filePath);
}
exec(sql) {
this._db.exec(sql);
return this;
}
pragma(str, options) {
if (str.includes("=")) {
this._db.exec(`PRAGMA ${str}`);
return undefined;
}
const row = this._db.prepare(`PRAGMA ${str}`).get();
if (!row) return undefined;
const keys = Object.keys(row);
if (options?.simple || keys.length === 1) return row[keys[0]];
return row;
}
prepare(sql) {
return this._db.prepare(sql);
}
transaction(fn) {
const db = this._db;
const wrapper = (...args) => {
db.exec("BEGIN");
try {
const result = fn(...args);
db.exec("COMMIT");
return result;
} catch (err) {
db.exec("ROLLBACK");
throw err;
}
};
return wrapper;
}
close() {
this._db.close();
}
}
module.exports = Database;
+32
View File
@@ -0,0 +1,32 @@
{
"id": "default",
"name": "Default feature pipeline",
"nodes": [
{ "id": "intake", "label": "intake", "icon": "📝", "gate": false, "aliases": ["assigned", "claimed", "start"] },
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false, "aliases": ["planning", "brainstorm", "design"],
"detect": [
{ "tool": "Skill", "match": "brainstorming|writing-plans" },
{ "tool": "Write", "match": "docs/.*plan.*\\.md" }
] },
{ "id": "implement", "label": "implement", "icon": "🛠", "gate": false, "aliases": ["implementing", "coding", "build"],
"detect": [
{ "tool": "Edit" },
{ "tool": "Write", "match": "^(?!.*(?:^|/)docs/)" }
] },
{ "id": "tests", "label": "tests", "icon": "🧪", "gate": true, "aliases": ["testing", "unit", "gates", "pre-push-gate"],
"detect": [
{ "tool": "Bash", "match": "\\b(npm (run )?test|pytest|vitest|jest|go test|cargo test)\\b" }
] },
{ "id": "review", "label": "review", "icon": "👀", "gate": true, "aliases": ["reviewing", "code-review", "self-review"],
"detect": [
{ "tool": "Skill", "match": "code-review|requesting-code-review" },
{ "tool": "Bash", "match": "git diff|gh pr diff" }
] },
{ "id": "gate", "label": "gate", "icon": "🚦", "gate": true, "aliases": ["verify", "verification", "sr-gate", "gate-blocked"] },
{ "id": "ship", "label": "ship", "icon": "🔀", "gate": false, "aliases": ["pr", "pr-open", "publishing", "commit", "push"],
"detect": [
{ "tool": "Bash", "match": "git push|gh pr create" }
] },
{ "id": "done", "label": "done", "icon": "✅", "gate": false, "aliases": ["complete", "completed", "merged"] }
]
}
+1590
View File
File diff suppressed because it is too large Load Diff
+996
View File
@@ -0,0 +1,996 @@
/**
* @file Sets up the Express server with API routes and WebSocket, serves the React client in production, and includes periodic maintenance tasks like session cleanup and compaction scanning.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
// Load .env file (simple key=value, no external dependency needed)
(function loadDotEnv() {
const fs = require("fs");
const os = require("os");
const envPath = require("path").resolve(__dirname, "..", ".env");
if (!fs.existsSync(envPath)) return;
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let val = trimmed.slice(eqIdx + 1).trim();
// Strip surrounding quotes (single or double)
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (!process.env[key]) {
process.env[key] = val.replace(/^~(?=\/)/, os.homedir());
}
}
})();
const express = require("express");
const cors = require("cors");
const path = require("path");
const http = require("http");
const swaggerUi = require("swagger-ui-express");
const { initWebSocket } = require("./websocket");
const { createOpenApiSpec } = require("./openapi");
const { redocBundlePath, renderRedocHtml } = require("./lib/redoc");
const { writeServerInfo, removeServerInfo, peersSharingDataDir } = require("./lib/server-info");
const { getDataDir } = require("./lib/claude-home");
const {
resolveHost,
isLoopbackHostname,
corsOptions,
hostGuard,
tokenGuard,
getDashboardToken,
} = require("./lib/security");
const sessionsRouter = require("./routes/sessions");
const agentsRouter = require("./routes/agents");
const eventsRouter = require("./routes/events");
const statsRouter = require("./routes/stats");
const hooksRouter = require("./routes/hooks");
const analyticsRouter = require("./routes/analytics");
const pricingRouter = require("./routes/pricing");
const settingsRouter = require("./routes/settings");
const workflowsRouter = require("./routes/workflows");
const pushRouter = require("./routes/push");
const importRouter = require("./routes/import");
const updatesRouter = require("./routes/updates");
const ccConfigRouter = require("./routes/cc-config");
const runRouter = require("./routes/run");
const alertsRouter = require("./routes/alerts");
const webhooksRouter = require("./routes/webhooks");
const remoteSourcesRouter = require("./routes/remote-sources");
const metricsRouter = require("./routes/metrics");
const lanesRouter = require("./routes/lanes");
const APP_VERSION = (() => {
try {
return require("../package.json").version || "0.0.0";
} catch {
return "0.0.0";
}
})();
function createApp() {
const app = express();
const openApiSpec = createOpenApiSpec();
// Security hardening (GHSA-gr74-4xfh-6jw9): loopback-only CORS, a Host-header
// allowlist (anti DNS-rebinding), and an optional bearer-token gate on /api/*.
app.use(cors(corsOptions()));
app.use(hostGuard);
app.use(express.json({ limit: "1mb" }));
app.use("/api", tokenGuard);
app.use("/api/sessions", sessionsRouter);
app.use("/api/agents", agentsRouter);
app.use("/api/events", eventsRouter);
app.use("/api/stats", statsRouter);
app.use("/api/hooks", hooksRouter);
app.use("/api/analytics", analyticsRouter);
app.use("/api/pricing", pricingRouter);
app.use("/api/settings", settingsRouter);
app.use("/api/workflows", workflowsRouter);
app.use("/api/push", pushRouter);
app.use("/api/import", importRouter);
app.use("/api/updates", updatesRouter);
app.use("/api/cc-config", ccConfigRouter);
app.use("/api/run", runRouter);
app.use("/api/lanes", lanesRouter);
app.use("/api/alerts", alertsRouter);
app.use("/api/webhooks", webhooksRouter);
app.use("/api/remote-sources", remoteSourcesRouter);
app.use("/api/metrics", metricsRouter);
app.get("/api/openapi.json", (_req, res) => {
res.json(openApiSpec);
});
app.use(
"/api/docs",
swaggerUi.serve,
swaggerUi.setup(openApiSpec, {
customSiteTitle: "Agent Dashboard API Docs",
})
);
// ReDoc — a read-optimized, three-panel rendering of the same OpenAPI spec
// (complements Swagger UI's interactive console at /api/docs). The bundle is
// served from node_modules, never a CDN, so the reference works offline.
app.get("/api/redoc/redoc.standalone.js", (_req, res) => {
res.sendFile(redocBundlePath(), (err) => {
if (err && !res.headersSent) res.status(500).end();
});
});
app.get("/api/redoc", (_req, res) => {
res
.type("html")
.send(
renderRedocHtml(
"/api/openapi.json",
"/api/redoc/redoc.standalone.js",
"Agent Dashboard API Reference"
)
);
});
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: APP_VERSION, timestamp: new Date().toISOString() });
});
return app;
}
function startServer(app, port) {
const server = http.createServer(app);
initWebSocket(server);
const isProduction = process.env.NODE_ENV === "production";
if (isProduction) {
const clientDist = path.join(__dirname, "..", "client", "dist");
// Cache policy designed to survive client rebuilds without forcing a hard
// refresh:
// - Hashed bundles under /assets/ never change for a given URL, so cache
// them aggressively (immutable).
// - index.html, /sw.js, and /manifest.json *are* the cache-bust signal,
// so they must revalidate every load — without this the browser's
// heuristic cache happily serves a stale index.html that references
// asset hashes that no longer exist on disk.
app.use(
express.static(clientDist, {
etag: true,
lastModified: true,
setHeaders(res, filePath) {
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
return;
}
const base = path.basename(filePath);
if (base === "index.html" || base === "sw.js" || base === "manifest.json") {
res.setHeader("Cache-Control", "no-cache, must-revalidate");
return;
}
// Other static files (favicon, og-image, etc.): short revalidation
// window — long enough to be friendly, short enough to recover from
// a typo without telling users to hard-refresh.
res.setHeader("Cache-Control", "public, max-age=300, must-revalidate");
},
})
);
app.get("*", (_req, res) => {
res.setHeader("Cache-Control", "no-cache, must-revalidate");
res.sendFile(path.join(clientDist, "index.html"));
});
}
// Bind to loopback by default so the dashboard is not network-reachable out
// of the box (GHSA-gr74-4xfh-6jw9). Operators opt into a wider bind with
// DASHBOARD_HOST=0.0.0.0 — and are warned to set DASHBOARD_TOKEN when they do.
const host = resolveHost();
const boundLoopback = isLoopbackHostname(host);
return new Promise((resolve) => {
server.listen(port, host, () => {
// Publish the live port so the Claude Code hook handler can find this
// server even when it bound a non-default port (the desktop app falls
// back off 4820 when that port is already taken).
writeServerInfo(port);
const sharedDbPeers = peersSharingDataDir();
if (sharedDbPeers.length > 0) {
const peerPorts = sharedDbPeers.map((p) => p.port).join(", ");
const ingestPort = Math.min(port, ...sharedDbPeers.map((p) => p.port));
console.warn(
`⚠️ Another dashboard is running on port(s) ${peerPorts} using the same database ` +
`(${getDataDir()}). Hooks ingest through port ${ingestPort} only to avoid duplicate events. ` +
`Stop extra instances if you do not need them.`
);
}
const mode = isProduction ? "production" : "development";
const shown = boundLoopback ? "localhost" : host;
console.log(`Agent Dashboard server running on http://${shown}:${port} (${mode})`);
if (!boundLoopback) {
console.warn(
`⚠️ Dashboard bound to ${host} — reachable from the network. ` +
(getDashboardToken()
? "DASHBOARD_TOKEN is set (API + WebSocket require it)."
: "Set DASHBOARD_TOKEN to require auth, or it is OPEN to anyone who can reach this port.")
);
}
if (!isProduction) {
console.log(`Client dev server expected at http://localhost:5173`);
}
resolve(server);
});
});
}
/**
* One-time bootstrap import of legacy Claude Code sessions from `~/.claude/`.
*
* Runs at most once per data directory, tracked by a `.legacy-import.done`
* marker file written next to the database. A marker rather than an "is the
* DB empty?" check is essential: the desktop app captures a live session via
* hooks before the user ever thinks about history, so an emptiness check would
* see a non-empty DB and skip the backfill forever, leaving every pre-existing
* session missing from the dashboard. The import itself is idempotent
* (per-session dedup), so running it against a DB that already holds some
* sessions simply adds the missing ones.
*
* Fire-and-forget the server does not await it. It lives in its own function
* (rather than inline in the `require.main` block, where it used to sit) so
* embedded hosts that call `startBackgroundServices()` notably the desktop
* app get the same first-launch backfill instead of an empty dashboard.
*/
function autoImportLegacySessions() {
try {
const fs = require("fs");
const dbModule = require("./db");
const markerPath = path.join(path.dirname(dbModule.DB_PATH), ".legacy-import.done");
if (fs.existsSync(markerPath)) return;
const { importAllSessions, backfillCompactions } = require("../scripts/import-history");
importAllSessions(dbModule)
.then(({ imported, errors }) => {
if (imported > 0) console.log(`Imported ${imported} legacy sessions from ~/.claude/`);
if (errors > 0) console.log(`${errors} session files had errors during import`);
})
.then(() => backfillCompactions(dbModule))
.then(({ backfilled }) => {
if (backfilled > 0)
console.log(`Backfilled ${backfilled} compaction events from ~/.claude/`);
})
// Backfill Workflow-tool run journals (issue #167) for all imported
// sessions. Inner agents emit no hooks, so this on-disk scan is the only
// way historical workflows surface.
.then(() => require("./lib/workflow-ingest").ingestAllWorkflows(dbModule))
.then(({ workflows }) => {
if (workflows > 0) console.log(`Backfilled ${workflows} workflow run(s) from ~/.claude/`);
})
// Write the marker only after the import completes, so a crash mid-import
// retries on the next start instead of being skipped forever.
.then(() => {
try {
fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`);
} catch {
/* non-fatal — worst case the (idempotent) import re-runs next start */
}
})
.catch(() => {});
} catch (err) {
console.warn("legacy session auto-import failed:", err.message);
}
}
/**
* Start the background services the dashboard relies on once the HTTP server
* is listening: a one-time legacy-session import, the upstream update
* scheduler, the Claude Code config watcher, and a one-time reconciliation of
* orphaned run rows.
*
* Exported so alternative hosts can bring up the same services the standalone
* `node server/index.js` path does. The desktop Electron shell `require()`s
* this module instead of running it as the main entry, so the
* `require.main === module` block below never executes for it.
*/
function startBackgroundServices() {
// One-time legacy-session backfill (a no-op once its marker file exists).
autoImportLegacySessions();
// A provisioning job belongs to the previous Node process and cannot survive
// a restart. Recover those rows now so the dashboard never shows a permanent
// provisioning spinner for work that was interrupted by the restart.
try {
const { recoverInterruptedProvisioning } = require("./lib/lanes");
const recovered = recoverInterruptedProvisioning();
if (recovered > 0) {
console.log(`[lanes] recovered ${recovered} interrupted provisioning lane(s) → failed`);
}
} catch (err) {
console.warn("lane provisioning recovery failed:", err.message);
}
// Boot liveness reap. When the user quit Claude Code while the dashboard
// was DOWN, the SessionEnd hook was lost and only the process probe can
// tell the session is dead — without this, such sessions sit in Waiting
// until a watchdog tick. Two passes, both fail-safe and off the startup
// critical path:
// 1. Immediately (next tick): reaps dead sessions ALREADY in the DB from
// a previous dashboard run — the common "app was up, app stopped,
// session quit, app starts" flow — so they never render as Waiting at
// all.
// 2. ~5 s later: reaps sessions the startup project sync just IMPORTED
// (rows that didn't exist at boot). The 15 s watchdog remains the
// safety net for anything later (kill -9 / crashes fire no SessionEnd
// either), and its probe is skipped whenever no active session
// qualifies, so the steady-state cost is nil.
// Both boot passes run with ignoreIdleGate: at boot the probe alone is the
// truth — a session quit even ONE second before launch must clear
// immediately, not after the LIVENESS_IDLE_SECONDS gate ages out (the gate
// exists to protect long-running steady-state work on watchdog ticks, and
// there is no in-flight work at boot).
{
const bootReap = (label) => {
try {
require("./routes/hooks").livenessReap({ ignoreIdleGate: true });
} catch (err) {
console.warn(`${label} liveness reap failed:`, err?.message || err);
}
};
setImmediate(() => bootReap("boot"));
const t = setTimeout(() => bootReap("post-import"), 5_000);
if (t.unref) t.unref();
}
// Backfill per-agent token metadata onto subagent rows that predate per-agent
// cost tracking, so their cards show their own cost instead of nothing. Runs
// deferred and non-blocking; self-limiting (rows with a tokens key are
// skipped), and metadata-only (never touches session token_usage).
{
const dbModule = require("./db");
const { backfillSubagentTokenMetadata } = require("../scripts/import-history");
const t = setTimeout(() => {
Promise.resolve()
.then(() => backfillSubagentTokenMetadata(dbModule))
.then((r) => {
if (r && r.stamped > 0)
console.log(
`Backfilled per-agent token cost for ${r.stamped} subagent(s) across ${r.sessions} session(s)`
);
})
.catch((err) => console.warn("subagent token backfill failed:", err?.message || err));
}, 500);
if (t.unref) t.unref();
}
const { startUpdateScheduler } = require("./update-scheduler");
const { broadcast } = require("./websocket");
startUpdateScheduler({ broadcast });
try {
const { startCcWatcher } = require("./lib/cc-watcher");
startCcWatcher({ broadcast });
} catch (err) {
console.warn("cc-watcher failed to start:", err.message);
}
// Near-real-time Workflow-tool run ingestion. The run journal is written when
// a workflow finishes — which may not coincide with a hook — so a fast,
// change-fingerprinted poll over active sessions keeps the UI fresh without
// waiting for the next Stop or the slow maintenance sweep.
try {
startWorkflowPoll(broadcast);
} catch (err) {
console.warn("workflow poll failed to start:", err.message);
}
// Continuous discovery of sessions under ~/.claude/projects. The one-time
// legacy backfill above runs only once (marker-gated), so a project added
// later whose sessions never flow through hooks would otherwise stay invisible
// until a manual rescan. This incremental, mtime-fingerprinted poll keeps the
// default folder in sync without re-parsing unchanged files.
try {
startSessionSync(broadcast);
} catch (err) {
console.warn("session sync failed to start:", err.message);
}
// Pull Claude Code history from enabled remote (SSH) sources on an interval so
// usage collected on other machines shows up here in near real time. Off by
// default cost-wise: the loop only does work when the user has configured at
// least one enabled source. Disable entirely with DASHBOARD_REMOTE_SYNC_MS=0.
try {
startRemoteSourceSync(broadcast);
} catch (err) {
console.warn("remote source sync failed to start:", err.message);
}
// Flip any dashboard_runs rows the previous process left flagged
// running/spawning — those handles died with the previous server, so
// there's no way to attach to them anymore. Marking them abandoned
// keeps the Run history honest and unblocks Resume on conversation rows.
try {
const { reconcileOrphans } = require("./lib/dashboard-runs");
const reconciled = reconcileOrphans();
if (reconciled > 0) {
console.log(`[runs] reconciled ${reconciled} orphan run(s) → abandoned`);
}
} catch (err) {
console.warn("dashboard-runs reconciliation failed:", err.message);
}
}
/**
* Periodic pull of Claude Code history from enabled remote (SSH) sources. Each
* tick rsyncs every enabled source's `~/.claude/projects` into a sandboxed
* staging dir and feeds it through the shared importer (see
* server/lib/remote-sync.js), so remote usage appears here in near real time.
* A first pass runs shortly after boot; thereafter every DASHBOARD_REMOTE_SYNC_MS
* (default 15s). Set the interval to 0 to disable. Unref'd so it never blocks
* shutdown; overlapping ticks queue one follow-up sweep (same as local sync).
*/
function startRemoteSourceSync(broadcast) {
const POLL_MS = process.env.DASHBOARD_REMOTE_SYNC_MS
? Number(process.env.DASHBOARD_REMOTE_SYNC_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
const dbModule = require("./db");
const { syncAllEnabled } = require("./lib/remote-sync");
let running = false;
let queued = false;
const tick = () => {
if (running) {
queued = true;
return;
}
// Cheap gate: skip all SSH work unless the user has an enabled source.
let count = 0;
try {
count = dbModule.stmts.listEnabledRemoteSources.all().length;
} catch {
return;
}
if (count === 0) return;
running = true;
Promise.resolve()
.then(() => syncAllEnabled(dbModule, { broadcast }))
.catch((err) => console.warn("remote source sync tick failed:", err?.message || err))
.finally(() => {
running = false;
if (queued) {
queued = false;
tick();
}
});
};
// First pass 2s after boot (let local import settle), then interval.
const boot = setTimeout(tick, 2_000);
if (boot.unref) boot.unref();
const timer = setInterval(tick, POLL_MS);
if (timer.unref) timer.unref();
}
/**
* Fast, change-fingerprinted poll that ingests Workflow-tool run journals for
* active sessions in near real time. Inner agent() calls emit no hooks and the
* journal lands at workflow completion, so this fills the gap between disk
* writes and the next hook/sweep. Skips sessions whose workflow artifacts are
* unchanged since the last ingest (cheap mtime fingerprint). Unref'd so it
* never blocks shutdown; disable with DASHBOARD_WORKFLOW_POLL_MS=0.
*/
function startWorkflowPoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_WORKFLOW_POLL_MS
? Number(process.env.DASHBOARD_WORKFLOW_POLL_MS)
: 12_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
const dbModule = require("./db");
const { ingestWorkflowsForSession, workflowsMaxMtime } = require("./lib/workflow-ingest");
const lastSeen = new Map(); // sessionId → newest workflow-artifact mtime ingested
const timer = setInterval(() => {
let active;
try {
active = dbModule.db
.prepare(
"SELECT id, transcript_path AS tp FROM sessions WHERE status = 'active' AND transcript_path IS NOT NULL ORDER BY updated_at DESC LIMIT 50"
)
.all();
} catch {
return;
}
for (const row of active) {
if (!row.tp) continue;
let mtime = 0;
try {
mtime = workflowsMaxMtime(row.tp);
} catch {
mtime = 0;
}
if (mtime === 0 || lastSeen.get(row.id) === mtime) continue; // none / unchanged
lastSeen.set(row.id, mtime);
ingestWorkflowsForSession(dbModule, { id: row.id, transcript_path: row.tp })
.then((changed) => {
if (!changed || changed.length === 0) return;
for (const wf of changed) broadcast("workflow_upserted", wf);
const sess = dbModule.stmts.getSession.get(row.id); // nudge cost refresh
if (sess) broadcast("session_updated", sess);
})
.catch(() => {});
}
}, POLL_MS);
if (timer.unref) timer.unref();
}
/**
* Keep the default `~/.claude/projects` directory in sync via three triggers
* that share one `mtimeCache` and a single coalesced sweep:
*
* 1. **Immediate** one sweep at startup, so a project the one-time backfill
* (`autoImportLegacySessions`, marker-gated) missed surfaces right away
* instead of after the first interval.
* 2. **Watcher** a debounced `fs.watch` on the projects tree fires a sweep
* the instant a *new* session file or project folder appears, so no-hook
* sessions show up immediately rather than on the next poll. Events for
* files already in `mtimeCache` (active transcripts being appended to) are
* ignored, so a busy session never thrashes the importer the poll picks
* up its growth. Recursive watching is used only on macOS/Windows (native,
* stable); on Linux, where Node's userland recursive watcher trips on the
* high-churn projects tree (see lib/cc-watcher.js), we watch the root plus
* each immediate child folder non-recursively instead.
* 3. **Poll** a periodic safety-net sweep (watchers can miss events / not
* fire on network filesystems). Tunable via `DASHBOARD_SESSION_SYNC_MS`
* (default 30 s); `0` disables the poll but leaves the watcher running.
*
* Each sweep parses only files whose mtime is new or has advanced, then
* broadcasts `session_created` for newly imported sessions / `session_updated`
* for grown ones the same events hooks emit, so the UI refreshes live. All
* timers and watchers are `unref`'d and best-effort; nothing here can block
* shutdown or take down the server.
*/
function startSessionSync(broadcast) {
const fs = require("fs");
const dbModule = require("./db");
const { getProjectsDir } = require("./lib/claude-home");
const { syncDefaultProjects } = require("../scripts/import-history");
const projectsDir = getProjectsDir();
const mtimeCache = new Map(); // filePath → newest mtime (ms) already imported
let running = false;
let queued = false; // a trigger arrived mid-sweep → run exactly once more
function runSweep() {
if (running) {
queued = true;
return;
}
running = true;
syncDefaultProjects(dbModule, { mtimeCache })
.then(({ changed }) => {
for (const { sessionId, isNew } of changed) {
let row;
try {
row = dbModule.stmts.getSession.get(sessionId);
} catch {
continue;
}
if (!row) continue;
broadcast(isNew ? "session_created" : "session_updated", row);
// Also surface the session's main agent, so a synced session appears
// live on the Agents board too (not just the Sessions board). Hooks
// emit both a session and an agent frame; mirror that here.
try {
const mainAgent = dbModule.db
.prepare("SELECT * FROM agents WHERE session_id = ? AND type = 'main' LIMIT 1")
.get(sessionId);
if (mainAgent) broadcast(isNew ? "agent_created" : "agent_updated", mainAgent);
} catch {
/* best-effort — the session frame already refreshed the UI */
}
}
})
.catch(() => {})
.finally(() => {
running = false;
if (queued) {
queued = false;
runSweep();
}
});
}
// 1. Deferred initial sweep — let the HTTP server and WebSocket handshake
// come up and serve the first page load before the (potentially heavy)
// cold catch-up sweep runs. On a machine with many grown transcripts, the
// cold sweep re-parses every file whose mtime is newer than its DB
// updated_at; running it inline at startup can monopolize the event loop
// long enough that the Vite `/ws` proxy handshake times out ("WebSocket is
// closed before the connection is established") and the dashboard looks
// stuck for a minute-plus. The sweep itself yields between heavy re-parses
// (see syncDefaultProjects), so once it starts it stays cooperative.
const initialSweep = setTimeout(runSweep, 250);
if (initialSweep.unref) initialSweep.unref();
// 3. Periodic safety net.
const POLL_MS = process.env.DASHBOARD_SESSION_SYNC_MS
? Number(process.env.DASHBOARD_SESSION_SYNC_MS)
: 30_000;
if (Number.isFinite(POLL_MS) && POLL_MS > 0) {
const timer = setInterval(runSweep, POLL_MS);
if (timer.unref) timer.unref();
}
// 2. Filesystem watcher — debounced, ignoring known-file churn.
const DEBOUNCE_MS = 800;
let debounce = null;
function scheduleSweep() {
if (debounce) return;
debounce = setTimeout(() => {
debounce = null;
runSweep();
}, DEBOUNCE_MS);
if (debounce.unref) debounce.unref();
}
// Only a path we don't already track is interesting (a new session file or a
// new project folder). Appends to a known active transcript are left to the
// poll, so the watcher never re-parses a busy session every write.
function onFsEvent(fullPath) {
if (fullPath && mtimeCache.has(fullPath)) return;
scheduleSweep();
}
const watchers = [];
function addWatcher(w) {
w.on("error", () => {});
if (w.unref) w.unref();
watchers.push(w);
}
const recursiveOk = process.platform === "darwin" || process.platform === "win32";
try {
if (fs.existsSync(projectsDir)) {
if (recursiveOk) {
addWatcher(
fs.watch(projectsDir, { recursive: true }, (_e, filename) => {
onFsEvent(filename ? path.join(projectsDir, filename) : null);
})
);
} else {
// Linux: watch the root (new folders) + each immediate child folder
// (new session files), adding a child watcher when a folder appears.
const watchChild = (dir) => {
try {
addWatcher(
fs.watch(dir, (_e, filename) => onFsEvent(filename ? path.join(dir, filename) : null))
);
} catch {
/* best-effort */
}
};
addWatcher(
fs.watch(projectsDir, (_e, filename) => {
if (filename) {
const child = path.join(projectsDir, filename);
try {
if (fs.statSync(child).isDirectory()) watchChild(child);
} catch {
/* removed before we could stat — ignore */
}
}
onFsEvent(filename ? path.join(projectsDir, filename) : null);
})
);
for (const ent of fs.readdirSync(projectsDir, { withFileTypes: true })) {
if (ent.isDirectory()) watchChild(path.join(projectsDir, ent.name));
}
}
}
} catch {
/* best-effort — the poll still keeps things in sync */
}
}
/**
* Resolve true when a healthy dashboard already answers `/api/health` on
* `port`. Used by the standalone entry point to avoid starting a SECOND server
* on the now-shared database two live servers would each persist the
* fanned-out hook events and double-count them. Never rejects; any
* error/timeout (nothing listening, or a non-dashboard process) resolves false.
*/
function probeDashboardHealth(port, timeoutMs = 1500) {
return new Promise((resolve) => {
const req = http.get(
{ host: "127.0.0.1", port, path: "/api/health", timeout: timeoutMs },
(res) => {
let buf = "";
res.setEncoding("utf8");
res.on("data", (c) => (buf += c));
res.on("end", () => {
try {
resolve(JSON.parse(buf)?.status === "ok");
} catch {
resolve(false);
}
});
}
);
req.on("error", () => resolve(false));
req.on("timeout", () => {
req.destroy();
resolve(false);
});
});
}
if (require.main === module) {
const PORT = parseInt(process.env.DASHBOARD_PORT || "4820", 10);
let httpServer = null;
// Single-server guard: if a healthy dashboard already owns this port, don't
// start a second one — both would write the fanned-out hook events into the
// shared database, double-counting them. Point the user at the running
// instance and exit. (`npm run dev` binds a free fallback port via
// scripts/dev.js, so this only trips when the conventional port is already
// serving a healthy dashboard — e.g. the desktop app, or another `npm start`.)
//
// Skip the guard under `node --watch` (dev:server): a watch restart briefly
// races the old process on the same port, and adopting there would wedge
// hot-reload. Dev already runs its own isolated server by design.
const isWatchMode = process.execArgv.some((a) => a.startsWith("--watch"));
probeDashboardHealth(PORT).then((alreadyRunning) => {
if (alreadyRunning && !isWatchMode) {
console.log(
`Agent Dashboard is already running on http://localhost:${PORT} — not starting a ` +
`second instance. Open that URL, or stop the other dashboard first.`
);
process.exit(0);
return;
}
const app = createApp();
startServer(app, PORT).then((server) => {
httpServer = server;
startBackgroundServices();
});
});
// Graceful shutdown — close connections and DB cleanly
let shutdownInProgress = false;
const shutdown = (signal) => {
if (shutdownInProgress) {
console.log(`\n${signal} received again — forcing immediate exit.`);
process.exit(1);
}
shutdownInProgress = true;
console.log(`\n${signal} received — shutting down gracefully… (hit Ctrl+C again to force)`);
// Drop realtime clients first — open WS sockets otherwise hold the HTTP
// server open and stall the shutdown until the force-exit backstop fires.
try {
require("./websocket").closeWebSocket();
} catch {
/* websocket may not be initialised */
}
const closeDb = () => {
try {
require("./db").db.close();
} catch {
/* already closed */
}
};
if (httpServer) {
// Close the DB only AFTER the HTTP server has fully drained. Closing it
// while requests are still in flight makes handlers throw "The database
// connection is not open" (e.g. server/routes/agents.js).
httpServer.close(() => {
console.log("HTTP server closed.");
closeDb();
process.exit(0);
});
// Drop lingering IDLE keep-alive sockets so close() fires promptly (under
// `node --watch` this turns a multi-second "waiting for graceful
// termination" stall into a near-instant restart) while letting in-flight
// requests finish and drain — the whole point of closing the DB in the
// close() callback. closeAllConnections() would kill in-flight requests
// too, so use it only as a fallback on runtimes without
// closeIdleConnections; the 5s backstop below covers a genuinely stuck
// request either way.
if (typeof httpServer.closeIdleConnections === "function") {
httpServer.closeIdleConnections();
} else if (typeof httpServer.closeAllConnections === "function") {
httpServer.closeAllConnections();
}
} else {
closeDb();
process.exit(0);
}
// Drop the port discovery file so a later run on a different port is not
// shadowed by a stale entry. (A crash skips this — the PID-liveness check
// in resolveDashboardPort() is the backstop for that case.)
removeServerInfo();
// Backstop: force exit if something still holds the event loop open. Close
// the DB here too — if close() never drained (a stuck in-flight request),
// the callback above never ran, so this is the only path that flushes
// SQLite before exit (closeDb is idempotent, so a normal drain is fine).
setTimeout(() => {
closeDb();
process.exit(0);
}, 5000).unref();
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
// Auto-install Claude Code hooks on every startup so users don't have to.
// Skipped inside containers (issue #193): a container-internal handler path
// would poison a bind-mounted host ~/.claude and break every host hook, so
// hooks must be installed on the host (`npm run install-hooks`).
try {
const { installHooks, isInsideContainer } = require("../scripts/install-hooks");
if (installHooks(true)) {
console.log("Claude Code hooks auto-configured.");
} else if (isInsideContainer()) {
console.log(
"Claude Code hooks NOT auto-configured: running inside a container. " +
"Run `npm run install-hooks` on the host so hooks point at a host path and " +
"POST to http://localhost:4820 (this container's published port)."
);
}
} catch {
// Non-fatal — user can run npm run install-hooks manually
}
// Periodic maintenance sweep:
// 1. Mark abandoned sessions that slipped through event-based detection
// 2. Scan active sessions' JSONL files for new compaction entries
// (/compact fires no hooks, so compaction agents only appear on next hook event
// without this scanner)
//
// Stale threshold: configurable via DASHBOARD_STALE_MINUTES env var.
// Default 180 (3 hours) — long enough that a coffee break, lunch, or even
// a meeting doesn't cause a Waiting session to flip to Abandoned/Completed
// out from under the user. The previous 5-min default was the main reason
// agents appeared to "go straight to completed" the moment Claude finished
// a turn: any pause longer than 5 min reaped the session, marking its main
// agent completed and emptying the Waiting column.
const STALE_MINUTES = (() => {
const raw = parseInt(process.env.DASHBOARD_STALE_MINUTES, 10);
return Number.isFinite(raw) && raw > 0 ? raw : 180;
})();
// Sweep interval: 1/4 of the stale threshold, clamped to [60s, 5 min].
// Frequent enough to catch real abandonments quickly, cheap enough that
// we're not hammering SQLite for nothing.
const SWEEP_INTERVAL_MS = Math.max(60_000, Math.min(300_000, (STALE_MINUTES * 60_000) / 4));
const cleanupDb = require("./db");
const { broadcast } = require("./websocket");
const { importCompactions } = require("../scripts/import-history");
const { transcriptCache } = require("./routes/hooks");
// Per-session newest workflow-artifact mtime already ingested by this sweep,
// so step 3 below skips sessions whose workflow files are unchanged (the same
// cheap fingerprint startWorkflowPoll uses). Declared once so it persists
// across sweep ticks.
const sweepWorkflowSeen = new Map();
setInterval(() => {
// 1. Stale session cleanup — batch agent updates to avoid N+1 queries
const stale = cleanupDb.stmts.findStaleSessions.all("__periodic__", STALE_MINUTES);
const now = new Date().toISOString();
if (stale.length > 0) {
const staleIds = stale.map((s) => s.id);
const placeholders = staleIds.map(() => "?").join(",");
// Batch update all non-terminal agents across all stale sessions
cleanupDb.db
.prepare(
`UPDATE agents SET status = 'completed', ended_at = COALESCE(ended_at, ?), updated_at = ?
WHERE session_id IN (${placeholders}) AND status NOT IN ('completed', 'error')`
)
.run(now, now, ...staleIds);
for (const s of stale) {
cleanupDb.stmts.updateSession.run(null, "abandoned", now, null, s.id);
broadcast("session_updated", cleanupDb.stmts.getSession.get(s.id));
// Evict transcript cache for abandoned sessions to bound memory growth.
// Reads transcript_path off the session row (populated by hooks
// ensureSession + one-time db.js backfill) instead of scanning events.
const tpRow = cleanupDb.db
.prepare("SELECT transcript_path AS tp FROM sessions WHERE id = ?")
.get(s.id);
if (tpRow?.tp) transcriptCache.invalidate(tpRow.tp);
}
// Broadcast updated agents once per stale session (not per-agent)
for (const s of stale) {
const agents = cleanupDb.stmts.listAgentsBySession.all(s.id);
for (const agent of agents) {
if (agent.status === "completed") {
broadcast("agent_updated", agent);
}
}
}
}
// 2. Scan active sessions for new compaction entries.
// Reads from sessions.transcript_path (populated by hooks ensureSession +
// one-time backfill in db.js migration) rather than scanning events —
// O(active sessions) instead of O(events rows).
const active = cleanupDb.db
.prepare(
"SELECT id AS session_id, transcript_path AS tp FROM sessions WHERE status = 'active' AND transcript_path IS NOT NULL ORDER BY updated_at DESC"
)
.all();
for (const row of active) {
if (!row.tp) continue;
try {
const compactions = transcriptCache.extractCompactions(row.tp);
if (compactions.length === 0) continue;
const mainAgentId = `${row.session_id}-main`;
const created = importCompactions(cleanupDb, row.session_id, mainAgentId, compactions);
if (created > 0) {
broadcast(
"agent_created",
cleanupDb.stmts.getAgent.get(
`${row.session_id}-compact-${compactions[compactions.length - 1].uuid}`
)
);
}
} catch (err) {
console.warn(
`[SWEEP] Compaction scan failed for session ${row.session_id}:`,
err?.message || err
);
continue;
}
}
// 3. Scan active sessions for Workflow-tool run journals (issue #167).
// Catches workflows that complete without a subsequent hook and flips
// launch-detected "running" rows to "completed" once their journal lands.
const { ingestWorkflowsForSession, workflowsMaxMtime } = require("./lib/workflow-ingest");
// Forget fingerprints for sessions that are no longer active so the map
// can't grow without bound over the process lifetime.
const activeIds = new Set(active.map((r) => r.session_id));
for (const id of sweepWorkflowSeen.keys()) {
if (!activeIds.has(id)) sweepWorkflowSeen.delete(id);
}
for (const row of active) {
if (!row.tp) continue;
// Skip sessions whose workflow artifacts are unchanged since the last
// ingest — the same cheap mtime fingerprint startWorkflowPoll uses.
// Without this the sweep full-re-parses every workflow journal and every
// inner agent-*.jsonl for every active session every cycle; on a large
// corpus that re-parse exceeds the sweep interval, sweeps overlap, and
// the event loop pegs (dashboard stops responding — white page).
let mtime = 0;
try {
mtime = workflowsMaxMtime(row.tp);
} catch {
mtime = 0;
}
if (mtime === 0 || sweepWorkflowSeen.get(row.session_id) === mtime) continue;
sweepWorkflowSeen.set(row.session_id, mtime);
ingestWorkflowsForSession(cleanupDb, { id: row.session_id, transcript_path: row.tp })
.then((changed) => {
if (!changed || changed.length === 0) return;
for (const wf of changed) broadcast("workflow_upserted", wf);
const sess = cleanupDb.stmts.getSession.get(row.session_id);
if (sess) broadcast("session_updated", sess);
})
.catch((err) => {
// Forget the fingerprint so the next sweep retries this session
// instead of skipping it until its artifacts change again.
sweepWorkflowSeen.delete(row.session_id);
console.warn(
`[SWEEP] Workflow scan failed for session ${row.session_id}:`,
err?.message || err
);
});
}
}, SWEEP_INTERVAL_MS);
// The one-time legacy-session import runs from startBackgroundServices()
// (called above) so the embedded desktop server backfills history too — not
// just this standalone path. See autoImportLegacySessions().
}
module.exports = { createApp, startServer, startBackgroundServices };
@@ -0,0 +1,669 @@
/**
* @file Unit tests for the TranscriptCache class, which extracts token usage from Claude transcript JSONL files and caches results for performance. Tests cover cache hits/misses, compaction detection, multiple models, and edge cases like malformed files and eviction behavior.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");
let tmpDir;
let TranscriptCache;
function writeJsonl(filePath, entries) {
fs.writeFileSync(filePath, entries.map((e) => JSON.stringify(e)).join("\n") + "\n");
}
describe("TranscriptCache", () => {
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tc-test-"));
delete require.cache[require.resolve("../../lib/transcript-cache")];
TranscriptCache = require("../../lib/transcript-cache");
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("should extract tokens on first read (cache miss)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 200, output_tokens: 75 },
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.deepStrictEqual(result.tokensByModel, {
"claude-sonnet-4-20250514": { input: 300, output: 125, cacheRead: 0, cacheWrite: 0 },
});
assert.strictEqual(result.compaction, null);
});
it("should return cached result when file is unchanged", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
]);
const cache = new TranscriptCache();
const r1 = cache.extract(file);
const r2 = cache.extract(file);
assert.deepStrictEqual(r1, r2);
// Same object reference proves cache hit (no re-parse)
assert.strictEqual(r1, r2);
});
it("should detect new data when file grows", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
]);
const cache = new TranscriptCache();
const r1 = cache.extract(file);
assert.strictEqual(r1.tokensByModel["claude-sonnet-4-20250514"].input, 100);
// Append more data (simulates Claude writing to transcript)
fs.appendFileSync(
file,
JSON.stringify({
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 200, output_tokens: 75 },
},
}) + "\n"
);
const r2 = cache.extract(file);
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 300);
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].output, 125);
});
it("should do full re-read when file shrinks (compaction rewrite)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 500, output_tokens: 200 },
},
},
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 300, output_tokens: 100 },
},
},
]);
const cache = new TranscriptCache();
cache.extract(file);
// Simulate compaction — file is rewritten with fewer entries + summary
writeJsonl(file, [
{ isCompactSummary: true, uuid: "abc-123", timestamp: "2026-03-20T10:00:00Z" },
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 50, output_tokens: 20 },
},
},
]);
const r2 = cache.extract(file);
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 50);
assert.strictEqual(r2.compaction.count, 1);
assert.strictEqual(r2.compaction.entries[0].uuid, "abc-123");
});
it("should return null for non-existent file", () => {
const cache = new TranscriptCache();
assert.strictEqual(cache.extract("/nonexistent/file.jsonl"), null);
assert.strictEqual(cache.extract(null), null);
assert.strictEqual(cache.extract(""), null);
});
it("should expose compaction entries via extractCompactions()", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{ isCompactSummary: true, uuid: "c1", timestamp: "2026-03-20T09:00:00Z" },
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 50, output_tokens: 20 },
},
},
{ isCompactSummary: true, uuid: "c2", timestamp: "2026-03-20T10:00:00Z" },
]);
const cache = new TranscriptCache();
const compactions = cache.extractCompactions(file);
assert.strictEqual(compactions.length, 2);
assert.strictEqual(compactions[0].uuid, "c1");
assert.strictEqual(compactions[1].uuid, "c2");
});
it("should handle multiple models in same file", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{
message: {
model: "claude-opus-4-20250514",
usage: { input_tokens: 500, output_tokens: 200 },
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].input, 100);
assert.strictEqual(result.tokensByModel["claude-opus-4-20250514"].input, 500);
});
it("should skip <synthetic> model entries", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "<synthetic>", usage: { input_tokens: 999, output_tokens: 999 } } },
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(Object.keys(result.tokensByModel).length, 1);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].input, 100);
});
it("should handle cache_read and cache_write tokens", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 30,
cache_creation_input_tokens: 15,
},
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].cacheRead, 30);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].cacheWrite, 15);
});
it("should remove entry on invalidate()", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } },
]);
const cache = new TranscriptCache();
cache.extract(file);
assert.strictEqual(cache.size, 1);
cache.invalidate(file);
assert.strictEqual(cache.size, 0);
});
it("should clear all entries", () => {
const file1 = path.join(tmpDir, "s1.jsonl");
const file2 = path.join(tmpDir, "s2.jsonl");
writeJsonl(file1, [
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
]);
writeJsonl(file2, [
{ message: { model: "m1", usage: { input_tokens: 20, output_tokens: 10 } } },
]);
const cache = new TranscriptCache();
cache.extract(file1);
cache.extract(file2);
assert.strictEqual(cache.size, 2);
cache.clear();
assert.strictEqual(cache.size, 0);
});
it("should return correct stats()", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
cache.extract(file);
const stats = cache.stats();
assert.strictEqual(stats.entries, 1);
assert.strictEqual(stats.paths.length, 1);
assert.strictEqual(stats.paths[0], file);
});
it("should only read new bytes on incremental update", () => {
const file = path.join(tmpDir, "session.jsonl");
const line1 =
JSON.stringify({
message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } },
}) + "\n";
fs.writeFileSync(file, line1);
const cache = new TranscriptCache();
cache.extract(file);
// Append a second line
const line2 =
JSON.stringify({
message: { model: "m1", usage: { input_tokens: 200, output_tokens: 75 } },
}) + "\n";
fs.appendFileSync(file, line2);
const r2 = cache.extract(file);
assert.strictEqual(r2.tokensByModel["m1"].input, 300);
// Verify bytesRead advanced to full file size
const entry = cache._cache.get(file);
assert.strictEqual(entry.bytesRead, Buffer.byteLength(line1 + line2, "utf8"));
});
it("should handle incremental read adding new compaction entries", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } },
]);
const cache = new TranscriptCache();
const r1 = cache.extract(file);
assert.strictEqual(r1.compaction, null);
// Append a compaction entry
fs.appendFileSync(
file,
JSON.stringify({ isCompactSummary: true, uuid: "new-c", timestamp: "2026-03-20T12:00:00Z" }) +
"\n"
);
const r2 = cache.extract(file);
assert.strictEqual(r2.compaction.count, 1);
assert.strictEqual(r2.compaction.entries[0].uuid, "new-c");
});
it("should return null for empty file", () => {
const file = path.join(tmpDir, "empty.jsonl");
fs.writeFileSync(file, "");
const cache = new TranscriptCache();
assert.strictEqual(cache.extract(file), null);
});
it("should skip malformed JSON lines gracefully", () => {
const file = path.join(tmpDir, "session.jsonl");
fs.writeFileSync(
file,
[
"not valid json",
JSON.stringify({
message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } },
}),
"{broken",
].join("\n") + "\n"
);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.tokensByModel["m1"].input, 100);
});
it("should evict oldest entries when exceeding maxEntries", () => {
const cache = new TranscriptCache(3); // max 3 entries
const files = [];
for (let i = 0; i < 5; i++) {
const file = path.join(tmpDir, `s${i}.jsonl`);
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: i * 10, output_tokens: i * 5 } } },
]);
files.push(file);
}
// Fill cache with 5 entries, but max is 3
for (const f of files) cache.extract(f);
assert.strictEqual(cache.size, 3);
// Oldest two (s0, s1) should be evicted; newest three (s2, s3, s4) remain
const stats = cache.stats();
assert.ok(!stats.paths.includes(files[0]), "oldest entry s0 should be evicted");
assert.ok(!stats.paths.includes(files[1]), "second-oldest entry s1 should be evicted");
assert.ok(stats.paths.includes(files[2]), "s2 should remain");
assert.ok(stats.paths.includes(files[3]), "s3 should remain");
assert.ok(stats.paths.includes(files[4]), "s4 should remain");
});
it("should refresh LRU order on access", () => {
const cache = new TranscriptCache(3);
const files = [];
for (let i = 0; i < 3; i++) {
const file = path.join(tmpDir, `lru${i}.jsonl`);
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
]);
files.push(file);
cache.extract(file);
}
// Access file[0] again (moves it to most-recently-used)
fs.appendFileSync(
files[0],
JSON.stringify({ message: { model: "m1", usage: { input_tokens: 5, output_tokens: 2 } } }) +
"\n"
);
cache.extract(files[0]);
// Add a new file — should evict file[1] (now the oldest), not file[0]
const newFile = path.join(tmpDir, "lru_new.jsonl");
writeJsonl(newFile, [
{ message: { model: "m1", usage: { input_tokens: 1, output_tokens: 1 } } },
]);
cache.extract(newFile);
assert.strictEqual(cache.size, 3);
const stats = cache.stats();
assert.ok(stats.paths.includes(files[0]), "recently accessed file should remain");
assert.ok(!stats.paths.includes(files[1]), "oldest untouched file should be evicted");
assert.ok(stats.paths.includes(files[2]), "file[2] should remain");
assert.ok(stats.paths.includes(newFile), "new file should be present");
});
it("should return defensive copy from extractCompactions", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ isCompactSummary: true, uuid: "c1", timestamp: "2026-03-20T09:00:00Z" },
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
]);
const cache = new TranscriptCache();
const compactions = cache.extractCompactions(file);
assert.strictEqual(compactions.length, 1);
// Mutate returned array — should NOT affect cache
compactions.push({ uuid: "fake", timestamp: null });
compactions[0].uuid = "mutated";
const compactions2 = cache.extractCompactions(file);
assert.strictEqual(compactions2.length, 1);
assert.strictEqual(compactions2[0].uuid, "c1");
});
it("should return empty array from extractCompactions for file with no compactions", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
const compactions = cache.extractCompactions(file);
assert.deepStrictEqual(compactions, []);
});
it("should return empty array from extractCompactions for non-existent file", () => {
const cache = new TranscriptCache();
const compactions = cache.extractCompactions("/nonexistent.jsonl");
assert.deepStrictEqual(compactions, []);
});
it("should capture lastInterruptTs from an Esc-interrupt entry (text marker)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
{
type: "user",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T12:00:00.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T12:00:00.000Z");
assert.strictEqual(result.pendingInterrupt, true);
});
it("should capture lastInterruptTs via the interruptedMessageId field", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "msg_123",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
},
timestamp: "2026-06-28T13:30:00.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T13:30:00.000Z");
assert.strictEqual(result.pendingInterrupt, true);
});
it("should flag pendingInterrupt for an Esc pressed BEFORE any output (prompt then interrupt)", () => {
// The hard case: user submits, then cancels before the model emits anything.
// Transcript order is [user prompt, interrupt] — no assistant entry between.
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
message: { role: "user", content: [{ type: "text", text: "do a big refactor" }] },
timestamp: "2026-06-28T15:00:00.000Z",
},
{
type: "user",
interruptedMessageId: "m",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T15:00:00.001Z", // 1ms later — the real-world skew case
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.pendingInterrupt, true, "pre-output Esc must still be detected");
});
it("should flag pendingInterrupt when Esc follows assistant output", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "assistant",
message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } },
timestamp: "2026-06-28T16:00:00.000Z",
},
{
type: "user",
interruptedMessageId: "m",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T16:00:05.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.pendingInterrupt, true);
});
it("should NOT flag pendingInterrupt when the user resumed after the interrupt", () => {
// [interrupt, new prompt] — the user came back and submitted again.
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "m",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T17:00:00.000Z",
},
{
type: "user",
message: { role: "user", content: [{ type: "text", text: "actually do this instead" }] },
timestamp: "2026-06-28T17:00:30.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T17:00:00.000Z");
assert.strictEqual(result.pendingInterrupt, false, "resuming with a new prompt clears it");
});
it("should keep the latest interrupt timestamp (append-only, last wins)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "a",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T10:00:00.000Z",
},
{
type: "user",
interruptedMessageId: "b",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T11:00:00.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T11:00:00.000Z");
assert.strictEqual(result.pendingInterrupt, true);
});
it("should carry a newer interrupt across an incremental read", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
const first = cache.extract(file);
assert.strictEqual(first.lastInterruptTs, null);
assert.strictEqual(first.pendingInterrupt, false);
// Append an interrupt entry → incremental read path must surface it.
fs.appendFileSync(
file,
JSON.stringify({
type: "user",
interruptedMessageId: "x",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T14:00:00.000Z",
}) + "\n"
);
const second = cache.extract(file);
assert.strictEqual(second.lastInterruptTs, "2026-06-28T14:00:00.000Z");
assert.strictEqual(second.pendingInterrupt, true);
});
it("should clear pendingInterrupt across an incremental read when the user resumes", () => {
// First read sees a tail interrupt; a later prompt appended in the next
// chunk must flip pendingInterrupt back to false via the merge path.
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "x",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T18:00:00.000Z",
},
]);
const cache = new TranscriptCache();
assert.strictEqual(cache.extract(file).pendingInterrupt, true);
fs.appendFileSync(
file,
JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "text", text: "resume" }] },
timestamp: "2026-06-28T18:01:00.000Z",
}) + "\n"
);
assert.strictEqual(cache.extract(file).pendingInterrupt, false, "incremental resume clears it");
});
it("should leave lastInterruptTs null and pendingInterrupt false when there is no interrupt", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, null);
assert.strictEqual(result.pendingInterrupt, false);
});
});
+320
View File
@@ -0,0 +1,320 @@
/**
* @file Rules-based alerting engine. Evaluates user-defined alert rules against
* live activity: event-driven rules (event_pattern, token_threshold) run on
* every hook ingest, time-based rules (inactivity, status_duration) run on a
* periodic sweep. Fired alerts are persisted to alert_events with per-scope
* cooldown dedup and broadcast to clients as `alert_triggered`.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { db, stmts } = require("../db");
const { broadcast } = require("../websocket");
const RULE_TYPES = ["event_pattern", "inactivity", "status_duration", "token_threshold"];
const AGENT_STATUSES = ["working", "waiting"];
// Enabled-rules cache. Hook ingest is hot — re-querying alert_rules on every
// event would be wasted work since rules only change through the CRUD routes,
// which call invalidateRuleCache().
let rulesCache = null;
function invalidateRuleCache() {
rulesCache = null;
}
function loadEnabledRules() {
if (rulesCache) return rulesCache;
rulesCache = stmts.listEnabledAlertRules.all().map((row) => {
let config = {};
try {
config = JSON.parse(row.config || "{}");
} catch {
/* tolerate hand-edited bad JSON — rule simply never matches */
}
return { ...row, config };
});
return rulesCache;
}
/**
* Validate and normalize a rule config for its type. Returns
* `{ ok: true, config }` with defaults applied, or `{ ok: false, error }`.
*/
function validateRuleConfig(ruleType, config) {
if (!RULE_TYPES.includes(ruleType)) {
return { ok: false, error: `rule_type must be one of: ${RULE_TYPES.join(", ")}` };
}
const cfg = config && typeof config === "object" && !Array.isArray(config) ? config : null;
if (!cfg) return { ok: false, error: "config must be an object" };
const num = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null);
switch (ruleType) {
case "event_pattern": {
const out = {};
for (const key of ["event_type", "tool_name", "summary_contains"]) {
if (cfg[key] != null) {
if (typeof cfg[key] !== "string" || !cfg[key].trim()) {
return { ok: false, error: `${key} must be a non-empty string` };
}
out[key] = cfg[key].trim();
}
}
if (!out.event_type && !out.tool_name && !out.summary_contains) {
return {
ok: false,
error: "event_pattern needs at least one of event_type, tool_name, summary_contains",
};
}
const count = cfg.count == null ? 1 : num(cfg.count);
if (!count || !Number.isInteger(count)) {
return { ok: false, error: "count must be a positive integer" };
}
out.count = count;
if (count > 1) {
const window = cfg.window_minutes == null ? 5 : num(cfg.window_minutes);
if (!window) return { ok: false, error: "window_minutes must be a positive number" };
out.window_minutes = window;
}
return { ok: true, config: out };
}
case "inactivity": {
const minutes = num(cfg.minutes);
if (!minutes) return { ok: false, error: "minutes must be a positive number" };
return { ok: true, config: { minutes } };
}
case "status_duration": {
if (!AGENT_STATUSES.includes(cfg.status)) {
return { ok: false, error: `status must be one of: ${AGENT_STATUSES.join(", ")}` };
}
const minutes = num(cfg.minutes);
if (!minutes) return { ok: false, error: "minutes must be a positive number" };
return { ok: true, config: { status: cfg.status, minutes } };
}
case "token_threshold": {
const total = num(cfg.total_tokens);
if (!total || !Number.isInteger(total)) {
return { ok: false, error: "total_tokens must be a positive integer" };
}
return { ok: true, config: { total_tokens: total } };
}
default:
return { ok: false, error: "unsupported rule_type" };
}
}
/**
* Fire an alert unless the same rule already fired for the same scope inside
* its cooldown window. Persists the alert row and broadcasts it. Returns the
* inserted row, or null when suppressed by cooldown.
*/
function fireAlert(rule, { sessionId = null, agentId = null, message, details = null }) {
const last = stmts.lastAlertFor.get(rule.id, sessionId, agentId);
if (last) {
const elapsedMs = Date.now() - new Date(last.triggered_at).getTime();
if (elapsedMs < rule.cooldown_seconds * 1000) return null;
}
const info = stmts.insertAlertEvent.run(
rule.id,
rule.name,
rule.rule_type,
sessionId,
agentId,
message,
details ? JSON.stringify(details) : null
);
const alert = stmts.getAlertEvent.get(info.lastInsertRowid);
broadcast("alert_triggered", alert);
// Fan out to configured webhook targets. Detached and fail-safe — webhook
// delivery must never slow or break alert firing. Lazy-required to keep the
// module graph acyclic and tolerate any load-order edge case.
try {
const { dispatchAlert } = require("./webhooks");
Promise.resolve(dispatchAlert(alert)).catch(() => {});
} catch (err) {
console.warn("[ALERTS] webhook dispatch failed:", err?.message || err);
}
return alert;
}
// Dynamic count-in-window queries vary by which pattern fields a rule sets;
// cache prepared statements by their SQL so hot rules don't re-prepare.
const countStmtCache = new Map();
function countMatchingEvents(sessionId, cfg) {
const where = ["session_id = ?", "created_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)"];
const params = [sessionId, `-${cfg.window_minutes * 60} seconds`];
if (cfg.event_type) {
where.push("event_type = ?");
params.push(cfg.event_type);
}
if (cfg.tool_name) {
where.push("tool_name = ?");
params.push(cfg.tool_name);
}
if (cfg.summary_contains) {
where.push("LOWER(COALESCE(summary, '')) LIKE ?");
params.push(`%${cfg.summary_contains.toLowerCase()}%`);
}
const sql = `SELECT COUNT(*) as count FROM events WHERE ${where.join(" AND ")}`;
let stmt = countStmtCache.get(sql);
if (!stmt) {
stmt = db.prepare(sql);
countStmtCache.set(sql, stmt);
}
return stmt.get(...params).count;
}
function matchesPattern(event, cfg) {
if (cfg.event_type && event.event_type !== cfg.event_type) return false;
if (cfg.tool_name && event.tool_name !== cfg.tool_name) return false;
if (
cfg.summary_contains &&
!(event.summary || "").toLowerCase().includes(cfg.summary_contains.toLowerCase())
) {
return false;
}
return true;
}
// Token totals only move on hooks that read the transcript — skip the SUM
// query for the rest of the event stream.
const TOKEN_BEARING_EVENTS = new Set(["PostToolUse", "Stop", "SubagentStop", "SessionEnd"]);
// Sweep queries are static — prepare once at module load instead of on every
// 60s tick. The time window arrives as a strftime modifier parameter.
const staleSessionsStmt = db.prepare(
`SELECT id, name FROM sessions
WHERE status = 'active'
AND updated_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)`
);
const stuckAgentsStmt = db.prepare(
`SELECT a.id, a.session_id, a.name FROM agents a
JOIN sessions s ON s.id = a.session_id
WHERE s.status = 'active' AND a.status = ?
AND a.updated_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)`
);
/**
* Evaluate event-driven rules against one freshly ingested event. Must never
* throw hook ingestion stays fail-safe regardless of rule misconfiguration.
*/
function evaluateEvent(event) {
if (!event || !event.session_id) return;
let rules;
try {
rules = loadEnabledRules();
} catch (err) {
console.warn("[ALERTS] rule load failed:", err?.message || err);
return;
}
for (const rule of rules) {
try {
if (rule.rule_type === "event_pattern") {
const cfg = rule.config;
if (!matchesPattern(event, cfg)) continue;
if (cfg.count > 1) {
const seen = countMatchingEvents(event.session_id, cfg);
if (seen < cfg.count) continue;
fireAlert(rule, {
sessionId: event.session_id,
agentId: event.agent_id || null,
message: `${rule.name}: ${seen} matching events in ${cfg.window_minutes} min (threshold ${cfg.count})`,
details: { matched: cfg, observed_count: seen, last_event_type: event.event_type },
});
} else {
fireAlert(rule, {
sessionId: event.session_id,
agentId: event.agent_id || null,
message: `${rule.name}: event matched (${event.event_type}${event.tool_name ? ` · ${event.tool_name}` : ""})`,
details: { matched: cfg, summary: event.summary || null },
});
}
} else if (rule.rule_type === "token_threshold") {
if (!TOKEN_BEARING_EVENTS.has(event.event_type)) continue;
const totals = stmts.sessionTokenTotals.get(event.session_id);
const total =
totals.input_tokens +
totals.output_tokens +
totals.cache_read_tokens +
totals.cache_write_tokens;
if (total < rule.config.total_tokens) continue;
fireAlert(rule, {
sessionId: event.session_id,
message: `${rule.name}: session used ${total.toLocaleString()} tokens (threshold ${rule.config.total_tokens.toLocaleString()})`,
details: { total_tokens: total, threshold: rule.config.total_tokens },
});
}
} catch (err) {
console.warn(`[ALERTS] rule "${rule.name}" evaluation failed:`, err?.message || err);
}
}
}
/**
* Evaluate time-based rules (inactivity, status_duration). Called by the
* periodic sweep; exported so tests can invoke it deterministically.
*/
function sweepTimeRules() {
let rules;
try {
rules = loadEnabledRules();
} catch (err) {
console.warn("[ALERTS] rule load failed:", err?.message || err);
return;
}
for (const rule of rules) {
try {
if (rule.rule_type === "inactivity") {
// sessions.updated_at is bumped on every ingested event (touchSession),
// so "stale updated_at on an active session" ≡ "no events for N min".
const stale = staleSessionsStmt.all(`-${rule.config.minutes * 60} seconds`);
for (const session of stale) {
fireAlert(rule, {
sessionId: session.id,
message: `${rule.name}: no activity on "${session.name || session.id}" for ${rule.config.minutes} min`,
details: { minutes: rule.config.minutes },
});
}
} else if (rule.rule_type === "status_duration") {
// agents.updated_at moves on any agent update (status flips, tool
// changes), so this detects agents *stuck* in a status with no
// activity — the hung-agent case the rule exists for.
const stuck = stuckAgentsStmt.all(
rule.config.status,
`-${rule.config.minutes * 60} seconds`
);
for (const agent of stuck) {
fireAlert(rule, {
sessionId: agent.session_id,
agentId: agent.id,
message: `${rule.name}: agent "${agent.name}" stuck in ${rule.config.status} for ${rule.config.minutes} min`,
details: { status: rule.config.status, minutes: rule.config.minutes },
});
}
}
} catch (err) {
console.warn(`[ALERTS] rule "${rule.name}" sweep failed:`, err?.message || err);
}
}
}
// Periodic sweep for the time-based rules. unref'd so it never keeps the
// process (or the test runner) alive — same pattern as the hooks watchdog.
const SWEEP_INTERVAL_MS = 60_000;
const sweepTimer = setInterval(sweepTimeRules, SWEEP_INTERVAL_MS);
if (sweepTimer.unref) sweepTimer.unref();
module.exports = {
RULE_TYPES,
validateRuleConfig,
evaluateEvent,
sweepTimeRules,
fireAlert,
invalidateRuleCache,
};
+270
View File
@@ -0,0 +1,270 @@
/**
* @file Safe archive extraction helpers for the history-import feature.
*
* Supports `.zip`, `.tar`, `.tar.gz`, `.tgz`, and plain `.gz` (single-file).
* Every entry is validated against path traversal (no absolute paths, no
* `..` segments) and resolved relative to the target directory. Non-regular
* entries (symlinks, devices, hardlinks) are skipped rather than extracted.
*
* All functions are async and never throw on unknown formats they return
* `{ extracted: number, skipped: number }` so routes can surface counts.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const zlib = require("zlib");
const { pipeline } = require("stream/promises");
const crypto = require("crypto");
/**
* Maximum total bytes any single archive is allowed to expand to during
* extraction. Tunable via env so deployments with huge legitimate archives
* can raise it; the default (4 GB) is generous for real-world transcript
* bundles but low enough to stop most zip-bomb attacks from filling disk.
*/
const MAX_EXTRACT_BYTES = parseInt(
process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES || String(4 * 1024 * 1024 * 1024),
10
);
class ExtractionLimitError extends Error {
constructor(limit) {
super(`Archive exceeded the ${limit}-byte extraction limit (possible zip bomb).`);
this.code = "EXTRACTION_LIMIT_EXCEEDED";
}
}
/**
* True if `child` is contained within `parent` after normalization.
* Used to reject archive entries that would escape the extraction root.
*/
function isPathInside(parent, child) {
const p = path.resolve(parent) + path.sep;
const c = path.resolve(child);
return c === path.resolve(parent) || c.startsWith(p);
}
/**
* Normalize an archive entry name: strip leading slashes, collapse `..`,
* reject if it escapes the root.
*/
function safeJoin(root, entryName) {
const cleaned = String(entryName).replace(/^[/\\]+/, "");
if (!cleaned || cleaned === "." || cleaned === "..") return null;
const joined = path.join(root, cleaned);
if (!isPathInside(root, joined)) return null;
return joined;
}
/**
* Create a unique temp directory for extraction under the OS tmpdir.
* Caller is responsible for cleanup via `rmTempDir`.
*/
function mkTempDir(prefix = "ccam-import-") {
const dir = path.join(os.tmpdir(), prefix + crypto.randomBytes(6).toString("hex"));
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function rmTempDir(dir) {
if (!dir) return;
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* non-fatal */
}
}
/**
* Extract a `.zip` archive into `destDir` using adm-zip.
* Lazily required so the dependency is optional at install time for users
* who don't need archive upload.
*/
async function extractZip(zipPath, destDir) {
let AdmZip;
try {
AdmZip = require("adm-zip");
} catch (err) {
throw new Error(
"adm-zip is required to extract .zip archives. Run `npm install` to pick up new deps."
);
}
const zip = new AdmZip(zipPath);
const entries = zip.getEntries();
// Pre-check declared uncompressed sizes so we reject obvious zip bombs
// before materializing any bytes to disk.
let declared = 0;
for (const entry of entries) {
if (!entry.isDirectory) declared += entry.header?.size || 0;
}
if (declared > MAX_EXTRACT_BYTES) throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
let extracted = 0;
let skipped = 0;
let writtenBytes = 0;
for (const entry of entries) {
if (entry.isDirectory) continue;
const target = safeJoin(destDir, entry.entryName);
if (!target) {
skipped++;
continue;
}
const data = entry.getData();
writtenBytes += data.length;
if (writtenBytes > MAX_EXTRACT_BYTES) throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
fs.mkdirSync(path.dirname(target), { recursive: true });
try {
fs.writeFileSync(target, data);
extracted++;
} catch {
skipped++;
}
}
return { extracted, skipped };
}
/**
* Extract a `.tar`, `.tar.gz`, or `.tgz` archive into `destDir`.
* Uses the `tar` package in streaming mode with `onentry` filter so we can
* enforce path containment ourselves rather than relying on the lib's flags.
*/
async function extractTar(tarPath, destDir) {
let tar;
try {
tar = require("tar");
} catch {
throw new Error(
"tar is required to extract .tar/.tar.gz archives. Run `npm install` to pick up new deps."
);
}
let extracted = 0;
let skipped = 0;
let writtenBytes = 0;
await tar.x({
file: tarPath,
cwd: destDir,
strict: false,
preservePaths: false,
filter: (entryPath, entry) => {
if (entry.type && entry.type !== "File" && entry.type !== "Directory") {
skipped++;
return false;
}
const target = safeJoin(destDir, entryPath);
if (!target) {
skipped++;
return false;
}
if (entry.type === "File") {
writtenBytes += entry.size || 0;
if (writtenBytes > MAX_EXTRACT_BYTES) {
// Surfacing the limit as a throw aborts tar.x; callers will see
// ExtractionLimitError in the catch path.
throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
}
extracted++;
}
return true;
},
});
return { extracted, skipped };
}
/**
* Decompress a plain `.gz` file (not a tar archive) into `destDir`, reusing
* the original filename with `.gz` stripped. Useful when a single JSONL was
* gzipped for transfer.
*/
async function extractGzSingle(gzPath, destDir) {
const base = path.basename(gzPath).replace(/\.gz$/i, "") || "decompressed.jsonl";
const target = safeJoin(destDir, base);
if (!target) return { extracted: 0, skipped: 1 };
fs.mkdirSync(path.dirname(target), { recursive: true });
// Count decompressed bytes as they flow through gunzip; abort if we blow
// past the extraction limit (defends against single-file gzip bombs).
let written = 0;
const { Transform } = require("stream");
const limiter = new Transform({
transform(chunk, _enc, cb) {
written += chunk.length;
if (written > MAX_EXTRACT_BYTES) {
cb(new ExtractionLimitError(MAX_EXTRACT_BYTES));
return;
}
cb(null, chunk);
},
});
await pipeline(
fs.createReadStream(gzPath),
zlib.createGunzip(),
limiter,
fs.createWriteStream(target)
);
return { extracted: 1, skipped: 0 };
}
/**
* Detect the archive kind from the filename. Returns one of:
* "zip" | "tar" | "tgz" | "gz" | "jsonl" | "meta" | "unknown"
*/
function detectKind(filename) {
const lower = filename.toLowerCase();
if (lower.endsWith(".zip")) return "zip";
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tgz";
if (lower.endsWith(".tar")) return "tar";
if (lower.endsWith(".meta.json")) return "meta";
if (lower.endsWith(".jsonl")) return "jsonl";
if (lower.endsWith(".gz")) return "gz";
return "unknown";
}
/**
* Dispatch to the right extractor based on filename. For plain `.jsonl` and
* `.meta.json` files we copy them through into `destDir`. Unknown files are
* skipped so users can drop mixed content without failures.
*/
async function extractInto(srcPath, destDir, originalName) {
const name = originalName || path.basename(srcPath);
const kind = detectKind(name);
switch (kind) {
case "zip":
return extractZip(srcPath, destDir);
case "tar":
case "tgz":
return extractTar(srcPath, destDir);
case "gz":
return extractGzSingle(srcPath, destDir);
case "jsonl":
case "meta": {
const target = safeJoin(destDir, name);
if (!target) return { extracted: 0, skipped: 1 };
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(srcPath, target);
return { extracted: 1, skipped: 0 };
}
default:
return { extracted: 0, skipped: 1 };
}
}
module.exports = {
mkTempDir,
rmTempDir,
extractInto,
extractZip,
extractTar,
extractGzSingle,
detectKind,
safeJoin,
isPathInside,
ExtractionLimitError,
MAX_EXTRACT_BYTES,
};
+831
View File
@@ -0,0 +1,831 @@
/**
* @file cc-discovery.js
* @description Read-only discovery of Claude Code configuration surfaces
* (skills, subagents, slash commands, output styles, plugins, marketplaces,
* MCP servers, hooks, settings, memory, keybindings, statusline, hook
* scripts). Powers the Claude Config Explorer page. All operations are pure
* file reads never writes.
*
* Path containment: every read resolves under getClaudeHome(),
* getProjectClaudeDir(), or getProjectRoot() (for CLAUDE.md). Reads outside
* those roots return null. Settings are redacted of secret-like keys before
* returning.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { getClaudeHome } = require("./claude-home");
const MAX_FILE_BYTES = 256 * 1024; // skip reads above this; truncate body in details
const REDACT_KEY_RE = /token|secret|password|api[_-]?key|auth/i;
function getProjectRoot(cwd) {
return path.resolve(cwd || process.cwd());
}
function getProjectClaudeDir(cwd) {
return path.join(getProjectRoot(cwd), ".claude");
}
function getClaudeJsonPath() {
// ~/.claude.json sits beside ~/.claude/, NOT inside it. Resolve from $HOME
// so a CLAUDE_HOME override doesn't accidentally relocate it.
return path.join(os.homedir(), ".claude.json");
}
/**
* True if `target` is contained within `root` (after symlink-aware resolve).
* Defends the /file endpoint against `..` traversal and absolute-path tricks.
*/
function isUnder(root, target) {
const r = path.resolve(root);
const t = path.resolve(target);
if (t === r) return true;
return t.startsWith(r + path.sep);
}
function readJson(absPath) {
try {
const raw = fs.readFileSync(absPath, "utf8");
return { ok: true, data: JSON.parse(raw), raw };
} catch (err) {
if (err && err.code === "ENOENT") return { ok: false, missing: true };
return { ok: false, error: err.message };
}
}
function redactSettings(value) {
if (Array.isArray(value)) return value.map(redactSettings);
if (value && typeof value === "object") {
const out = {};
for (const [k, v] of Object.entries(value)) {
if (typeof v === "string" && REDACT_KEY_RE.test(k)) {
out[k] = "<redacted>";
} else {
out[k] = redactSettings(v);
}
}
return out;
}
return value;
}
/**
* Minimal YAML-frontmatter parser. Handles `---\n<key>: <value>\n---\n<body>`.
* Quoted strings (single + double) are stripped; multi-line values are
* preserved as raw strings. Anything we can't parse is returned as null
* frontmatter the body is still readable.
*/
function parseFrontmatter(text) {
if (typeof text !== "string") return { frontmatter: null, body: "" };
if (!text.startsWith("---")) return { frontmatter: null, body: text };
const end = text.indexOf("\n---", 3);
if (end < 0) return { frontmatter: null, body: text };
const head = text.slice(3, end).replace(/^\s*\n/, "");
const body = text.slice(end + 4).replace(/^\s*\n/, "");
const fm = {};
let currentKey = null;
for (const rawLine of head.split("\n")) {
const line = rawLine.replace(/\s+$/, "");
if (!line.trim()) continue;
// continuation of a multiline value
if (currentKey && /^\s/.test(rawLine)) {
fm[currentKey] += "\n" + rawLine.trim();
continue;
}
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!m) {
currentKey = null;
continue;
}
currentKey = m[1];
let v = m[2];
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
fm[currentKey] = v;
}
return { frontmatter: fm, body };
}
function safeReadText(absPath) {
try {
const stat = fs.statSync(absPath);
if (!stat.isFile()) return null;
if (stat.size > MAX_FILE_BYTES) {
return {
truncated: true,
size: stat.size,
text: fs.readFileSync(absPath, "utf8").slice(0, MAX_FILE_BYTES),
mtime: stat.mtimeMs,
};
}
return {
truncated: false,
size: stat.size,
text: fs.readFileSync(absPath, "utf8"),
mtime: stat.mtimeMs,
};
} catch {
return null;
}
}
function listDir(absPath) {
try {
return fs.readdirSync(absPath, { withFileTypes: true });
} catch {
return [];
}
}
/**
* True if `ent` is a directory, OR a symlink that resolves to one.
* Dirent.isDirectory() returns false for symlinks even when they point at a
* directory (e.g. a skill installed via `ln -s` so it can live in a git
* repo) this follows the link with statSync so those aren't skipped.
* Broken symlinks are treated as non-directories rather than throwing.
*/
function isDirLike(ent, absPath) {
if (ent.isDirectory()) return true;
if (!ent.isSymbolicLink()) return false;
try {
return fs.statSync(absPath).isDirectory();
} catch {
return false;
}
}
/**
* File counterpart of {@link isDirLike}: true if `ent` is a regular file, OR a
* symlink that resolves to one. Same Dirent quirk `ent.isFile()` returns
* false for a symlink even when it points at a file, so agents/commands/hook
* scripts installed via `ln -s` were invisible to the Config Explorer while
* Claude Code itself resolves and uses them. Broken symlinks are treated as
* non-files rather than throwing.
*/
function isFileLike(ent, absPath) {
if (ent.isFile()) return true;
if (!ent.isSymbolicLink()) return false;
try {
return fs.statSync(absPath).isFile();
} catch {
return false;
}
}
// ── Skills ──────────────────────────────────────────────────────────────
function readSkillsAt(scope, claudeDir) {
const dir = path.join(claudeDir, "skills");
const entries = listDir(dir);
const skills = [];
for (const ent of entries) {
const skillDir = path.join(dir, ent.name);
if (!isDirLike(ent, skillDir)) continue;
const skillFile = path.join(skillDir, "SKILL.md");
const read = safeReadText(skillFile);
if (!read) continue;
const { frontmatter, body } = parseFrontmatter(read.text);
skills.push({
scope,
name: ent.name,
path: skillDir,
file: skillFile,
size: read.size,
mtime: read.mtime,
truncated: read.truncated,
frontmatter: frontmatter || {},
preview: body.slice(0, 320),
});
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
function readSkills(opts = {}) {
const out = [];
if (opts.scope !== "project") {
out.push(...readSkillsAt("user", getClaudeHome()));
}
if (opts.scope !== "user") {
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
}
return out;
}
// ── Single-file MD surfaces (agents, commands, output styles) ──────────
function readMdFilesAt(scope, claudeDir, subdir) {
const dir = path.join(claudeDir, subdir);
const entries = listDir(dir);
const out = [];
for (const ent of entries) {
if (!ent.name.endsWith(".md")) continue;
const file = path.join(dir, ent.name);
if (!isFileLike(ent, file)) continue;
const read = safeReadText(file);
if (!read) continue;
const { frontmatter, body } = parseFrontmatter(read.text);
out.push({
scope,
name: ent.name.replace(/\.md$/, ""),
file,
size: read.size,
mtime: read.mtime,
truncated: read.truncated,
frontmatter: frontmatter || {},
preview: body.slice(0, 320),
});
}
return out.sort((a, b) => a.name.localeCompare(b.name));
}
function readSimpleMdSurface(subdir) {
return (opts = {}) => {
const out = [];
if (opts.scope !== "project") {
out.push(...readMdFilesAt("user", getClaudeHome(), subdir));
}
if (opts.scope !== "user") {
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
}
return out;
};
}
const readAgents = readSimpleMdSurface("agents");
const readCommands = readSimpleMdSurface("commands");
const readOutputStyles = readSimpleMdSurface("output-styles");
// ── Plugins ─────────────────────────────────────────────────────────────
function countMdIn(dir) {
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.name.endsWith(".md") && isFileLike(e, path.join(dir, e.name))).length;
} catch {
return 0;
}
}
function countSkillDirsIn(dir) {
try {
return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => {
if (!isDirLike(e, path.join(dir, e.name))) return false;
try {
return fs.statSync(path.join(dir, e.name, "SKILL.md")).isFile();
} catch {
return false;
}
}).length;
} catch {
return 0;
}
}
function readPluginContributions(installPath) {
if (!installPath) return null;
let pluginJson = null;
try {
const raw = fs.readFileSync(path.join(installPath, ".claude-plugin", "plugin.json"), "utf8");
pluginJson = JSON.parse(raw);
} catch {
pluginJson = null;
}
return {
skills: countSkillDirsIn(path.join(installPath, "skills")),
agents: countMdIn(path.join(installPath, "agents")),
commands: countMdIn(path.join(installPath, "commands")),
outputStyles: countMdIn(path.join(installPath, "output-styles")),
hooks: (() => {
try {
return fs
.readdirSync(path.join(installPath, "hooks"), { withFileTypes: true })
.filter((e) => isFileLike(e, path.join(installPath, "hooks", e.name))).length;
} catch {
return 0;
}
})(),
pluginJson,
};
}
function readEnabledPluginsMap() {
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
if (!userSettings.ok || !userSettings.data) return {};
const ep = userSettings.data.enabledPlugins;
return ep && typeof ep === "object" ? ep : {};
}
function readPlugins() {
const home = getClaudeHome();
const manifestPath = path.join(home, "plugins", "installed_plugins.json");
const manifest = readJson(manifestPath);
const enabledMap = readEnabledPluginsMap();
const plugins = [];
if (manifest.ok && manifest.data && manifest.data.plugins) {
for (const [pluginKey, instances] of Object.entries(manifest.data.plugins)) {
const arr = Array.isArray(instances) ? instances : [instances];
for (const inst of arr) {
const installPath = inst.installPath;
let exists = false;
try {
exists = installPath ? fs.statSync(installPath).isDirectory() : false;
} catch {
exists = false;
}
const contributes = exists ? readPluginContributions(installPath) : null;
// enabledPlugins map keys can be just the plugin name OR "<name>@<marketplace>"
const enabledByKey = enabledMap[pluginKey];
const enabledByName = enabledMap[pluginKey.split("@")[0]];
const enabled =
enabledByKey === true || enabledByName === true
? true
: enabledByKey === false || enabledByName === false
? false
: null;
plugins.push({
key: pluginKey,
name: pluginKey.split("@")[0],
marketplace: pluginKey.includes("@") ? pluginKey.split("@")[1] : null,
scope: inst.scope || "user",
version: inst.version || null,
installPath: installPath || null,
installedAt: inst.installedAt || null,
lastUpdated: inst.lastUpdated || null,
gitCommitSha: inst.gitCommitSha || null,
installPathExists: exists,
enabled,
contributes,
});
}
}
}
return {
manifestPath,
manifestExists: manifest.ok,
plugins: plugins.sort((a, b) => a.key.localeCompare(b.key)),
};
}
// ── MCP servers ────────────────────────────────────────────────────────
function readMcpServers(opts = {}) {
const out = { user: [], projectScoped: [] };
// ~/.claude.json is the primary CLI state file; mcpServers can live at the
// top level (legacy) or inside projects[<cwd>].mcpServers (per-project).
const claudeJson = readJson(getClaudeJsonPath());
if (claudeJson.ok && claudeJson.data) {
const top = claudeJson.data.mcpServers;
if (top && typeof top === "object") {
for (const [name, def] of Object.entries(top)) {
out.user.push({
name,
source: "~/.claude.json (top-level)",
...summarizeMcpDef(def),
});
}
}
const projects = claudeJson.data.projects;
if (projects && typeof projects === "object") {
const projectRoot = getProjectRoot(opts.cwd);
const projectEntry = projects[projectRoot];
if (projectEntry && projectEntry.mcpServers) {
for (const [name, def] of Object.entries(projectEntry.mcpServers)) {
out.projectScoped.push({
name,
source: `~/.claude.json (projects[${projectRoot}])`,
...summarizeMcpDef(def),
});
}
}
}
}
// Also sniff settings.json for an mcpServers key (rare but supported).
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
if (userSettings.ok && userSettings.data && userSettings.data.mcpServers) {
for (const [name, def] of Object.entries(userSettings.data.mcpServers)) {
out.user.push({
name,
source: "~/.claude/settings.json",
...summarizeMcpDef(def),
});
}
}
return out;
}
function summarizeMcpDef(def) {
if (!def || typeof def !== "object") return { kind: "unknown" };
if (def.url)
return { kind: "http", url: def.url, headers: def.headers ? Object.keys(def.headers) : [] };
if (def.command) {
return {
kind: "stdio",
command: def.command,
args: Array.isArray(def.args) ? def.args : [],
envNames: def.env && typeof def.env === "object" ? Object.keys(def.env) : [],
};
}
return { kind: "unknown" };
}
// ── Hooks (read across user + project + project-local) ─────────────────
const HOOK_EVENT_TYPES = [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"Notification",
"PreCompact",
];
function readHooks(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
{
scope: "project",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
},
{
scope: "project-local",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
},
];
const result = [];
for (const { scope, file } of sources) {
const j = readJson(file);
const entry = { scope, file, exists: j.ok, hooks: {} };
if (j.ok && j.data && j.data.hooks && typeof j.data.hooks === "object") {
for (const event of HOOK_EVENT_TYPES) {
const matchers = j.data.hooks[event];
if (!Array.isArray(matchers)) continue;
const flat = [];
for (const m of matchers) {
const matcher = m.matcher || "*";
const list = Array.isArray(m.hooks) ? m.hooks : [];
for (const h of list) {
flat.push({
matcher,
type: h.type || "command",
command: h.command || null,
timeout: h.timeout || null,
});
}
}
if (flat.length) entry.hooks[event] = flat;
}
// Also surface unknown events the user wrote
for (const [event, matchers] of Object.entries(j.data.hooks)) {
if (HOOK_EVENT_TYPES.includes(event)) continue;
if (!Array.isArray(matchers)) continue;
entry.hooks[event] = matchers;
}
}
result.push(entry);
}
return result;
}
// ── Settings ───────────────────────────────────────────────────────────
function readSettings(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
{
scope: "project",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
},
{
scope: "project-local",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
},
];
return sources.map(({ scope, file }) => {
const j = readJson(file);
if (!j.ok) return { scope, file, exists: false };
return {
scope,
file,
exists: true,
data: redactSettings(j.data),
raw_size: j.raw.length,
};
});
}
// ── Marketplaces ───────────────────────────────────────────────────────
function readMarketplaces() {
const home = getClaudeHome();
const knownPath = path.join(home, "plugins", "known_marketplaces.json");
const known = readJson(knownPath);
const out = [];
if (known.ok && known.data && typeof known.data === "object") {
for (const [name, def] of Object.entries(known.data)) {
const installLocation = def && def.installLocation;
const sourceDef = def && def.source;
let pluginCount = null;
let marketplaceJson = null;
if (installLocation) {
try {
const mfPath = path.join(installLocation, ".claude-plugin", "marketplace.json");
const raw = fs.readFileSync(mfPath, "utf8");
marketplaceJson = JSON.parse(raw);
pluginCount = Array.isArray(marketplaceJson.plugins)
? marketplaceJson.plugins.length
: null;
} catch {
/* not all marketplaces have a manifest */
}
}
out.push({
name,
source: sourceDef && typeof sourceDef === "object" ? sourceDef : null,
installLocation: installLocation || null,
lastUpdated: def && def.lastUpdated ? def.lastUpdated : null,
pluginCount,
marketplaceName: marketplaceJson?.name || null,
marketplaceDescription: marketplaceJson?.description || null,
marketplaceOwner: marketplaceJson?.owner || null,
});
}
}
return {
knownPath,
knownExists: known.ok,
items: out.sort((a, b) => a.name.localeCompare(b.name)),
};
}
// ── Keybindings ────────────────────────────────────────────────────────
function readKeybindings() {
const file = path.join(getClaudeHome(), "keybindings.json");
const j = readJson(file);
if (!j.ok) return { file, exists: false };
const data = j.data && typeof j.data === "object" ? j.data : {};
const groups = Array.isArray(data.bindings) ? data.bindings : [];
return {
file,
exists: true,
schema: data.$schema || null,
docs: data.$docs || null,
groups: groups.map((g) => ({
context: g.context || "",
bindings:
g.bindings && typeof g.bindings === "object"
? Object.entries(g.bindings).map(([key, action]) => ({ key, action: String(action) }))
: [],
})),
};
}
// ── Statusline (config + script content) ──────────────────────────────
function readStatusline() {
const userSettingsPath = path.join(getClaudeHome(), "settings.json");
const j = readJson(userSettingsPath);
const config = j.ok && j.data && j.data.statusLine ? j.data.statusLine : null;
const candidates = [
path.join(getClaudeHome(), "statusline.py"),
path.join(getClaudeHome(), "statusline-command.sh"),
];
const scripts = [];
for (const file of candidates) {
const r = safeReadText(file);
if (r) {
scripts.push({
file,
size: r.size,
mtime: r.mtime,
truncated: r.truncated,
preview: r.text.slice(0, 4000),
});
}
}
return { config, scripts };
}
// ── Hook handler scripts dir (~/.claude/hooks/) ───────────────────────
function readHookScripts() {
const dir = path.join(getClaudeHome(), "hooks");
const entries = listDir(dir);
return {
dir,
items: entries
.filter((e) => isFileLike(e, path.join(dir, e.name)))
.map((e) => {
const file = path.join(dir, e.name);
let stat;
try {
stat = fs.statSync(file);
} catch {
return null;
}
return { name: e.name, file, size: stat.size, mtime: stat.mtimeMs };
})
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name)),
};
}
// ── Memory (CLAUDE.md + per-project file-based memory) ─────────────────
// Index/manifest files inside a memory dir (MEMORY.md, INDEX-*.md) sort
// before the per-fact files so the table-of-contents shows up first.
const MEMORY_INDEX_RE = /^(MEMORY|INDEX)\b/i;
/**
* Read the two primary CLAUDE.md memory files (user + project) PLUS every
* markdown file under ~/.claude/projects/<slug>/memory/ the common
* community pattern of a file-based agent memory store (a MEMORY.md index
* plus one file per remembered fact). The latter are emitted with
* scope "auto-memory" and carry `project` (the projects/<slug> dir name)
* and `name` (the filename) so the UI can group + label them. They are
* mutable via cc-mutate's "auto-memory" type (create/edit/delete + backup).
*/
function readMemory(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "CLAUDE.md") },
{ scope: "project", file: path.join(getProjectRoot(opts.cwd), "CLAUDE.md") },
];
const result = [];
for (const { scope, file } of sources) {
const r = safeReadText(file);
if (!r) continue;
result.push({
scope,
file,
size: r.size,
mtime: r.mtime,
truncated: r.truncated,
preview: r.text.slice(0, 480),
});
}
// Per-project file-based memory dirs. Best-effort: a missing projects
// root, an unreadable memory dir, or a single bad file must never break
// the memory tab — every layer is wrapped so we degrade to "fewer files".
try {
const projectsRoot = path.join(getClaudeHome(), "projects");
for (const proj of fs.readdirSync(projectsRoot)) {
const memDir = path.join(projectsRoot, proj, "memory");
let files;
try {
files = fs.readdirSync(memDir);
} catch {
continue;
}
files = files
.filter((f) => f.endsWith(".md"))
.sort((a, b) => {
const rank = (f) => (MEMORY_INDEX_RE.test(f) ? 0 : 1);
return rank(a) - rank(b) || a.localeCompare(b);
});
for (const f of files) {
const file = path.join(memDir, f);
const r = safeReadText(file);
if (!r) continue;
// Per-fact memory files commonly carry YAML frontmatter (name,
// description, metadata.type) — parse it like the other MD surfaces
// so the UI can show a clean title + description instead of raw text.
const { frontmatter, body } = parseFrontmatter(r.text);
result.push({
scope: "auto-memory",
project: proj,
name: f,
isIndex: MEMORY_INDEX_RE.test(f),
file,
size: r.size,
mtime: r.mtime,
truncated: r.truncated,
frontmatter: frontmatter || {},
preview: (body || r.text).slice(0, 480),
});
}
}
} catch {
/* best-effort: never break the memory tab */
}
return result;
}
// ── Single-file body endpoint (with strict path containment) ───────────
function readFileSafe(absPath, opts = {}) {
const allowedRoots = [
getClaudeHome(),
getProjectClaudeDir(opts.cwd),
getProjectRoot(opts.cwd), // for CLAUDE.md only — caller must pass exact name
];
const resolved = path.resolve(absPath);
const inside = allowedRoots.some((root) => isUnder(root, resolved));
if (!inside) return { error: "path is outside allowed roots" };
// Extra guard: under project root we only allow CLAUDE.md (avoid leaking
// arbitrary repo files via this endpoint).
if (
isUnder(getProjectRoot(opts.cwd), resolved) &&
!isUnder(getProjectClaudeDir(opts.cwd), resolved) &&
path.basename(resolved) !== "CLAUDE.md"
) {
return { error: "only CLAUDE.md is readable from project root" };
}
const r = safeReadText(resolved);
if (!r) return { error: "file not readable" };
return { ok: true, file: resolved, ...r };
}
// ── Overview (counts + roots) ──────────────────────────────────────────
function readOverview(opts = {}) {
const skills = readSkills(opts);
const agents = readAgents(opts);
const commands = readCommands(opts);
const outputStyles = readOutputStyles(opts);
const plugins = readPlugins();
const mcp = readMcpServers(opts);
const hooks = readHooks(opts);
const settings = readSettings(opts);
const memory = readMemory(opts);
const marketplaces = readMarketplaces();
const keybindings = readKeybindings();
const countByScope = (arr) => ({
user: arr.filter((x) => x.scope === "user").length,
project: arr.filter((x) => x.scope === "project").length,
});
const enabledPlugins = plugins.plugins.filter((p) => p.enabled === true).length;
const disabledPlugins = plugins.plugins.filter((p) => p.enabled === false).length;
const keybindingTotal = keybindings.exists
? keybindings.groups.reduce((n, g) => n + g.bindings.length, 0)
: 0;
return {
roots: {
claudeHome: getClaudeHome(),
projectClaudeDir: getProjectClaudeDir(opts.cwd),
projectRoot: getProjectRoot(opts.cwd),
claudeJson: getClaudeJsonPath(),
},
counts: {
skills: countByScope(skills),
agents: countByScope(agents),
commands: countByScope(commands),
outputStyles: countByScope(outputStyles),
plugins: plugins.plugins.length,
pluginsEnabled: enabledPlugins,
pluginsDisabled: disabledPlugins,
marketplaces: marketplaces.items.length,
keybindings: keybindingTotal,
mcpServers: { user: mcp.user.length, project: mcp.projectScoped.length },
hooks: hooks.reduce(
(acc, src) => {
acc[src.scope] = Object.values(src.hooks).reduce(
(n, arr) => n + (Array.isArray(arr) ? arr.length : 0),
0
);
return acc;
},
{ user: 0, project: 0, "project-local": 0 }
),
memory: memory.length,
settingsFiles: settings.filter((s) => s.exists).length,
},
};
}
module.exports = {
// surface readers
readSkills,
readAgents,
readCommands,
readOutputStyles,
readPlugins,
readMcpServers,
readHooks,
readSettings,
readMemory,
readMarketplaces,
readKeybindings,
readStatusline,
readHookScripts,
readOverview,
readFileSafe,
// helpers exported for tests
parseFrontmatter,
redactSettings,
isUnder,
isFileLike,
MAX_FILE_BYTES,
HOOK_EVENT_TYPES,
};
+535
View File
@@ -0,0 +1,535 @@
/**
* @file cc-mutate.js
* @description Mutation helpers for the Claude Config Explorer. Handles
* create / overwrite / delete on the low-risk text-file surfaces only:
* skills, subagents, slash commands, output styles, CLAUDE.md memory, and
* per-project file-based memory (~/.claude/projects/<slug>/memory/*.md).
*
* Hard constraints (do not relax without a follow-up review):
* - Plugins, MCP servers, hooks-in-settings, and settings.json files are
* NEVER touched here. Those have concurrent-write races with the live
* Claude Code CLI and need different handling.
* - Every write/delete creates a timestamped backup BEFORE the mutation.
* Backups land under <root>/cc-config-backups/<type>/, well outside the
* directories Claude Code scans, so a deleted skill cannot reappear as
* a backup-named skill.
* - Writes are atomic via temp file + fs.renameSync. Tmp is removed on
* any failure path.
* - Names are validated against a strict allowlist regex; resolved paths
* are double-checked to live under the expected root before any I/O.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const { isUnder, MAX_FILE_BYTES } = require("./cc-discovery");
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
// Auto-memory files are arbitrary flat *.md filenames inside a project's
// memory dir; the project is the ~/.claude/projects/<slug> dir name.
const MEMORY_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/i;
// Project slugs are an absolute cwd with "/" → "-", so they begin with "-".
// Allow alnum/_/- as the first char (never "." — blocks hidden/weird dirs);
// traversal is additionally blocked by the !includes("..") + isUnder guards.
const PROJECT_SLUG_RE = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,255}$/;
const TYPES = {
skills: { kind: "dir", subdir: "skills", filename: "SKILL.md" },
agents: { kind: "file", subdir: "agents", ext: ".md" },
commands: { kind: "file", subdir: "commands", ext: ".md" },
"output-styles": { kind: "file", subdir: "output-styles", ext: ".md" },
memory: { kind: "memory" }, // CLAUDE.md at root, no `name`
// Per-project file-based memory: ~/.claude/projects/<project>/memory/<name>.md.
// Keyed by (project, name); scope is irrelevant (always under CLAUDE_HOME).
"auto-memory": { kind: "auto-memory" },
};
function getProjectRoot(cwd) {
return path.resolve(cwd || process.cwd());
}
function getProjectClaudeDir(cwd) {
return path.join(getProjectRoot(cwd), ".claude");
}
function rootForScope(scope, opts = {}) {
if (scope === "user") return getClaudeHome();
if (scope === "project") return getProjectClaudeDir(opts.cwd);
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
function memoryPathForScope(scope, opts = {}) {
if (scope === "user") return path.join(getClaudeHome(), "CLAUDE.md");
if (scope === "project") return path.join(getProjectRoot(opts.cwd), "CLAUDE.md");
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
/**
* Resolve (and validate) the memory dir for a per-project file-based memory
* store: ~/.claude/projects/<project>/memory/. Rejects slugs that could
* traverse out of the projects root.
*/
function autoMemoryDir(project) {
if (typeof project !== "string" || !PROJECT_SLUG_RE.test(project) || project.includes("..")) {
throw makeError("EBADPROJECT", `invalid project slug: ${project}`);
}
const projectsRoot = path.join(getClaudeHome(), "projects");
const dir = path.join(projectsRoot, project, "memory");
if (!isUnder(projectsRoot, dir)) {
throw makeError("EOUTOFROOT", "project escapes the projects root");
}
return dir;
}
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/**
* Resolve the on-disk target for a (scope, type, name) tuple AND the
* containment root used for path-traversal checks.
*
* Returns:
* { kind: "file" | "dir" | "memoryFile",
* target: <abs path of file or skill dir>,
* filePath: <abs path of the actual .md file inside target>,
* containmentRoot: <abs path that must contain target> }
*/
function resolveTarget(scope, type, name, opts = {}) {
const spec = TYPES[type];
if (!spec) throw makeError("EBADTYPE", `unknown type: ${type}`);
if (spec.kind === "memory") {
const filePath = memoryPathForScope(scope, opts);
// Memory's containment root is the parent dir (CLAUDE_HOME or project root).
return {
kind: "memoryFile",
target: filePath,
filePath,
containmentRoot: path.dirname(filePath),
};
}
if (spec.kind === "auto-memory") {
const memDir = autoMemoryDir(opts.project);
if (typeof name !== "string" || !MEMORY_FILE_RE.test(name) || name.includes("..")) {
throw makeError("EBADNAME", `auto-memory name must be a flat *.md filename`);
}
const target = path.join(memDir, name);
return { kind: "file", target, filePath: target, containmentRoot: memDir };
}
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw makeError("EBADNAME", `name must match ${NAME_RE}`);
}
const root = rootForScope(scope, opts);
const subdirAbs = path.join(root, spec.subdir);
if (spec.kind === "dir") {
const target = path.join(subdirAbs, name);
return {
kind: "dir",
target,
filePath: path.join(target, spec.filename),
containmentRoot: subdirAbs,
};
}
// file
const target = path.join(subdirAbs, name + spec.ext);
return {
kind: "file",
target,
filePath: target,
containmentRoot: subdirAbs,
};
}
function backupRoot(scope, type, opts = {}) {
return path.join(rootForScope(scope, opts), "cc-config-backups", type);
}
function memoryBackupRoot(scope, opts = {}) {
// Memory's "type" for backup bookkeeping is just "memory"; root sits beside
// the file itself.
const dir = path.dirname(memoryPathForScope(scope, opts));
return path.join(dir, ".cc-config-backups", "memory");
}
function autoMemoryBackupRoot(memDir) {
// Backups live in a dotted subdir of the memory dir. Claude Code only loads
// *.md directly in the dir, so .bak files tucked under a subdir stay inert.
return path.join(memDir, ".cc-config-backups", "auto-memory");
}
function timestamp() {
return new Date().toISOString().replace(/[:]/g, "-");
}
function copyDirSync(src, dst) {
fs.mkdirSync(dst, { recursive: true });
for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, ent.name);
const d = path.join(dst, ent.name);
if (ent.isDirectory()) copyDirSync(s, d);
else if (ent.isFile()) fs.copyFileSync(s, d);
// symlinks/sockets/etc skipped intentionally — these surfaces are
// text-file-only by spec
}
}
function rmTreeSync(p) {
fs.rmSync(p, { recursive: true, force: true });
}
/**
* Always-on backup. For files, copies to <backupRoot>/<name>.<ts>.bak. For
* dirs (skills), copies the whole tree. Returns the backup path (or null
* if there was nothing to back up e.g. brand-new file).
*/
function createBackup({ scope, type, target, kind, opts }) {
if (!fs.existsSync(target)) return null;
let root;
if (type === "memory") root = memoryBackupRoot(scope, opts);
else if (type === "auto-memory") root = autoMemoryBackupRoot(path.dirname(target));
else root = backupRoot(scope, type, opts);
fs.mkdirSync(root, { recursive: true });
const base = path.basename(target);
const stamp = timestamp();
if (kind === "dir") {
const dst = path.join(root, `${base}.${stamp}.bak`);
copyDirSync(target, dst);
return dst;
}
// file
const dst = path.join(root, `${base}.${stamp}.bak`);
fs.copyFileSync(target, dst);
return dst;
}
/**
* Atomic write: tmp file fsync (best-effort) rename. Tmp is unlinked
* on any failure path. Caller is responsible for ensuring parent dir exists.
*/
function atomicWriteFile(filePath, content) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
let fd;
try {
fd = fs.openSync(tmp, "wx");
fs.writeSync(fd, content);
try {
fs.fsyncSync(fd);
} catch {
// fsync may fail on some filesystems / tmpfs — non-fatal
}
fs.closeSync(fd);
fd = null;
fs.renameSync(tmp, filePath);
} catch (err) {
try {
if (fd != null) fs.closeSync(fd);
} catch {
/* ignore */
}
try {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
} catch {
/* ignore */
}
throw err;
}
}
// ── Public API ─────────────────────────────────────────────────────────
/**
* Create or overwrite a single text artifact. Returns metadata including
* the backup path (null if this was a fresh create).
*
* @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args
*/
function writeArtifact(args) {
const { scope, type, name, content, cwd, project } = args;
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const r = resolveTarget(scope, type, name, { cwd, project });
// Containment guard: even after our regex, double-check that the resolved
// path actually lives under the expected root. Defends against quirks like
// Windows drive letters or normalize-then-resolve mismatches.
if (!isUnder(r.containmentRoot, r.target)) {
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
}
const existedBefore = fs.existsSync(r.filePath);
const backupPath = existedBefore
? createBackup({
scope,
type,
target: r.kind === "dir" ? r.target : r.filePath,
kind: r.kind,
opts: { cwd },
})
: null;
if (r.kind === "dir") {
fs.mkdirSync(r.target, { recursive: true });
}
atomicWriteFile(r.filePath, content);
return {
ok: true,
file: r.filePath,
target: r.target,
backupPath,
created: !existedBefore,
};
}
/**
* Delete a single text artifact. Backup is mandatory and runs first; if
* the backup fails, the original is left intact.
*/
function deleteArtifact(args) {
const { scope, type, name, cwd, project } = args;
const r = resolveTarget(scope, type, name, { cwd, project });
if (!isUnder(r.containmentRoot, r.target)) {
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
}
if (!fs.existsSync(r.target)) {
throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`);
}
const backupPath = createBackup({
scope,
type,
target: r.target,
kind: r.kind === "memoryFile" ? "file" : r.kind,
opts: { cwd },
});
if (r.kind === "dir") {
rmTreeSync(r.target);
} else {
fs.unlinkSync(r.target);
}
return { ok: true, file: r.filePath, target: r.target, backupPath };
}
// ── Keybindings (structured JSON edit) ─────────────────────────────────
//
// keybindings.json is a single user-scope JSON file (~/.claude/keybindings.json).
// Unlike settings.json / ~/.claude.json it is not rewritten mid-session by the
// live CLI, so a backup-before-write edit is safe. We read-modify-write: any
// existing top-level keys ($schema, $docs, and anything we don't model) are
// preserved and only the `bindings` array is replaced, so metadata is never
// dropped. Backups land under CLAUDE_HOME/cc-config-backups/keybindings/.
function keybindingsFile() {
return path.join(getClaudeHome(), "keybindings.json");
}
function keybindingsBackupRoot() {
return path.join(getClaudeHome(), "cc-config-backups", "keybindings");
}
// A keybinding key ("ctrl+t", "escape", "shift+ctrl+f") or action id
// ("toggleTodos"). Bounded, non-empty, single-line printable text.
function validKbString(s, max) {
return typeof s === "string" && s.trim().length >= 1 && s.length <= max && !/[\r\n\t]/.test(s);
}
/**
* Overwrite ~/.claude/keybindings.json from a structured list of groups. Each
* group is `{ context, bindings: [{ key, action }] }`; on disk the bindings
* become an object keyed by `key`. Validates shape, rejects duplicate contexts
* and duplicate keys within a context, backs up the existing file first, then
* writes atomically. Returns `{ ok, file, backupPath, created }`.
*
* @param {{ groups: Array<{context:string, bindings:Array<{key:string,action:string}>}> }} args
*/
function writeKeybindings(args = {}) {
const { groups } = args;
if (!Array.isArray(groups)) {
throw makeError("EBADCONTENT", "groups must be an array");
}
if (groups.length > 200) {
throw makeError("ETOOLARGE", "too many keybinding contexts (max 200)");
}
const outBindings = [];
const seenContexts = new Set();
for (const g of groups) {
if (!g || typeof g !== "object") {
throw makeError("EBADCONTENT", "each group must be an object");
}
const context = typeof g.context === "string" ? g.context.trim() : "";
if (!validKbString(context, 128)) {
throw makeError("EBADCONTENT", "each group needs a non-empty context (<= 128 chars)");
}
if (seenContexts.has(context)) {
throw makeError("EBADCONTENT", `duplicate context: ${context}`);
}
seenContexts.add(context);
const list = Array.isArray(g.bindings) ? g.bindings : [];
if (list.length > 1000) {
throw makeError("ETOOLARGE", `too many bindings in context ${context} (max 1000)`);
}
const map = {};
for (const b of list) {
if (!b || typeof b !== "object") {
throw makeError("EBADCONTENT", `each binding in context ${context} must be an object`);
}
const key = typeof b.key === "string" ? b.key.trim() : "";
const action = typeof b.action === "string" ? b.action.trim() : "";
if (!validKbString(key, 64)) {
throw makeError("EBADCONTENT", `invalid key in context ${context}`);
}
if (!validKbString(action, 128)) {
throw makeError("EBADCONTENT", `invalid action for key "${key}" in context ${context}`);
}
if (Object.prototype.hasOwnProperty.call(map, key)) {
throw makeError("EBADCONTENT", `duplicate key "${key}" in context ${context}`);
}
map[key] = action;
}
outBindings.push({ context, bindings: map });
}
const file = keybindingsFile();
// Preserve any existing top-level metadata ($schema, $docs, unknown keys).
let base = {};
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed;
} catch {
base = {};
}
const nextObj = { ...base, bindings: outBindings };
const content = JSON.stringify(nextObj, null, 2) + "\n";
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const existedBefore = fs.existsSync(file);
let backupPath = null;
if (existedBefore) {
const root = keybindingsBackupRoot();
fs.mkdirSync(root, { recursive: true });
backupPath = path.join(root, `keybindings.json.${timestamp()}.bak`);
fs.copyFileSync(file, backupPath);
}
atomicWriteFile(file, content);
return { ok: true, file, target: file, backupPath, created: !existedBefore };
}
/**
* List backups for either all types or a specific (scope, type) bucket.
* Returns [{ scope, type, name, backupPath, mtime, size }].
*/
function listBackups(opts = {}) {
const out = [];
const scopes = opts.scope ? [opts.scope] : ["user", "project"];
// auto-memory backups live per-project, not under a user/project root — they
// are scanned separately below.
const types = (opts.type ? [opts.type] : Object.keys(TYPES)).filter((t) => t !== "auto-memory");
for (const scope of scopes) {
if (scope === "auto-memory") continue;
for (const type of types) {
const root =
type === "memory" ? memoryBackupRoot(scope, opts) : backupRoot(scope, type, opts);
let entries = [];
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
}
for (const ent of entries) {
const full = path.join(root, ent.name);
let stat;
try {
stat = fs.statSync(full);
} catch {
continue;
}
out.push({
scope,
type,
name: ent.name,
backupPath: full,
isDir: ent.isDirectory(),
mtime: stat.mtimeMs,
size: ent.isDirectory() ? null : stat.size,
});
}
}
}
// Per-project auto-memory backups: ~/.claude/projects/<slug>/memory/
// .cc-config-backups/auto-memory/. Best-effort — never throw.
const wantAuto =
(!opts.type || opts.type === "auto-memory") && (!opts.scope || opts.scope === "auto-memory");
if (wantAuto) {
try {
const projectsRoot = path.join(getClaudeHome(), "projects");
for (const proj of fs.readdirSync(projectsRoot)) {
const root = autoMemoryBackupRoot(path.join(projectsRoot, proj, "memory"));
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
}
for (const ent of entries) {
const full = path.join(root, ent.name);
let stat;
try {
stat = fs.statSync(full);
} catch {
continue;
}
out.push({
scope: "auto-memory",
project: proj,
type: "auto-memory",
name: ent.name,
backupPath: full,
isDir: ent.isDirectory(),
mtime: stat.mtimeMs,
size: ent.isDirectory() ? null : stat.size,
});
}
}
} catch {
/* ignore */
}
}
return out.sort((a, b) => b.mtime - a.mtime);
}
module.exports = {
writeArtifact,
deleteArtifact,
writeKeybindings,
listBackups,
resolveTarget, // exported for tests
TYPES,
NAME_RE,
};
+212
View File
@@ -0,0 +1,212 @@
/**
* @file cc-watcher.js
* @description Best-effort file watcher for the Claude Code config surfaces
* surfaced by the Config Explorer page. Watches ~/.claude/ recursively (if
* the platform supports it) plus ~/.claude.json and emits a debounced
* `cc_config_changed` over the dashboard websocket so the UI can refetch
* without polling.
*
* Aggressively filters fs.watch events: ~/.claude/ contains lots of churn
* (`projects/*.jsonl` transcripts, `file-history/`, our own
* `cc-config-backups/`) that has nothing to do with the Config Explorer.
* Only paths matching real config surfaces fire a broadcast. Without this
* filter the watcher fires multiple times per second while a claude session
* is active and the page becomes a perpetual loading spinner.
*
* Failures here are non-fatal `fs.watch` is platform-quirky, and the
* Config Explorer still has a manual Refresh button.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const { getClaudeHome } = require("./claude-home");
const DEBOUNCE_MS = 500;
// Subpaths inside ~/.claude/ that ARE config surfaces and should trigger a
// refetch. Anything else (transcripts, file history, our own backups) is
// ignored. Match is by prefix on the relative path.
const RELEVANT_PREFIXES = [
"settings.json",
"settings.local.json",
"keybindings.json",
"statusline.py",
"statusline-command.sh",
"known_marketplaces.json",
"agents",
"commands",
"skills",
"output-styles",
"hooks",
"plugins",
"CLAUDE.md",
];
// Subpaths to explicitly ignore even if they match RELEVANT_PREFIXES by
// accident. Important: our own backup dir lives at ~/.claude/cc-config-backups/
// and writing backups would re-trigger the watcher in a loop without this.
const IGNORED_PREFIXES = [
"cc-config-backups",
"backups", // Claude Code's own ~/.claude/backups/.claude.json.backup.* churn
"projects",
"file-history",
"todos",
"shell-snapshots",
"ide",
"logs",
"statsig",
];
// Config surfaces that are DIRECTORIES — watched recursively so nested changes
// (e.g. skills/<x>/SKILL.md) still fire. We deliberately watch ONLY these,
// never the whole of ~/.claude/, so the recursive watcher never registers
// interest in high-churn dirs (backups/, projects/, logs/) whose transient
// files crash Node's Linux userland recursive watcher mid-stat.
const WATCH_SUBDIRS = ["agents", "commands", "skills", "output-styles", "hooks", "plugins"];
let started = false;
let timer = null;
let pendingPaths = new Set();
const watchers = [];
function isRelevantUnderHome(home, fullPath) {
const rel = path.relative(home, fullPath);
if (!rel || rel.startsWith("..")) return false;
// First segment of the relative path
const head = rel.split(path.sep)[0];
if (IGNORED_PREFIXES.includes(head)) return false;
if (!RELEVANT_PREFIXES.includes(head)) return false;
return true;
}
function scheduleEmit(broadcast, p) {
if (p) pendingPaths.add(p);
if (timer) return;
timer = setTimeout(() => {
timer = null;
const paths = Array.from(pendingPaths);
pendingPaths = new Set();
if (paths.length === 0) return;
try {
broadcast("cc_config_changed", { source: "fs", paths });
} catch {
/* ignore */
}
}, DEBOUNCE_MS);
}
function safeWatchHome({ home, broadcast }) {
if (!fs.existsSync(home)) return;
// Watch ~/.claude/ itself NON-recursively. Catches top-level config files
// (settings.json, keybindings.json, CLAUDE.md, statusline.*, *.json) plus the
// creation/removal of subdirs. Non-recursive does NOT walk-and-stat children,
// so it never trips the recursive-watcher ENOENT race on churn dirs.
try {
const w = fs.watch(home, (_event, filename) => {
if (!filename) return;
const full = path.join(home, filename);
if (!isRelevantUnderHome(home, full)) return;
scheduleEmit(broadcast, full);
});
w.on("error", () => {});
watchers.push(w);
} catch {
/* platform limitation — best effort only */
}
// Recursively watch ONLY the relevant config subdirs (never backups/, projects/,
// logs/, …) so nested changes still fire without watching the high-churn trees.
for (const sub of WATCH_SUBDIRS) {
const dir = path.join(home, sub);
try {
if (!fs.existsSync(dir)) continue;
const w = fs.watch(dir, { recursive: true }, (_event, filename) => {
const full = filename ? path.join(dir, filename) : dir;
if (!isRelevantUnderHome(home, full)) return;
scheduleEmit(broadcast, full);
});
w.on("error", () => {});
watchers.push(w);
} catch {
/* platform limitation — best effort only */
}
}
}
function safeWatchFile({ target, broadcast }) {
try {
if (!fs.existsSync(target)) return;
const w = fs.watch(target, () => scheduleEmit(broadcast, target));
w.on("error", () => {});
watchers.push(w);
} catch {
/* ignore */
}
}
// Belt-and-suspenders: Node's recursive fs.watch (userland impl on Linux) stats
// changed paths and can throw ENOENT/EPERM when a file vanishes mid-event. That
// throw escapes the watcher's `error` event as an uncaughtException. This watcher
// is explicitly best-effort and must NEVER take down the server, so swallow
// exactly that class of error and let every other uncaught exception crash as
// normal (print + non-zero exit, matching Node's default).
let crashGuard = null;
function installWatchCrashGuard() {
if (crashGuard) return;
crashGuard = (err) => {
const stack = (err && err.stack) || "";
const transientWatch =
err &&
(err.code === "ENOENT" || err.code === "EPERM") &&
err.syscall === "stat" &&
/fs[\\/](recursive_watch|watchers)/.test(stack);
if (transientWatch) return; // vanished file under a watched tree — ignore
// Not ours: preserve default crash behavior.
console.error(err);
process.exit(1);
};
process.on("uncaughtException", crashGuard);
}
function uninstallWatchCrashGuard() {
if (!crashGuard) return;
process.removeListener("uncaughtException", crashGuard);
crashGuard = null;
}
/**
* Start watching the Claude Code config surfaces. Idempotent: subsequent
* calls are no-ops.
*/
function startCcWatcher({ broadcast }) {
if (started) return;
started = true;
installWatchCrashGuard();
const home = getClaudeHome();
safeWatchHome({ home, broadcast });
// ~/.claude.json sits beside ~/.claude/, not inside it.
safeWatchFile({ target: path.join(os.homedir(), ".claude.json"), broadcast });
}
function stopCcWatcher() {
if (timer) {
clearTimeout(timer);
timer = null;
}
for (const w of watchers) {
try {
w.close();
} catch {
/* ignore */
}
}
watchers.length = 0;
pendingPaths = new Set();
uninstallWatchCrashGuard();
started = false;
}
module.exports = { startCcWatcher, stopCcWatcher, isRelevantUnderHome };
+296
View File
@@ -0,0 +1,296 @@
/**
* @file claude-home.js
* @description Centralized Claude Code home directory path management.
* Resolves the projects directory, transcript paths (main + per-subagent),
* and settings file location. Supports a custom root via the CLAUDE_HOME
* environment variable (e.g. ~/.codefuse/engine/cc/) so the dashboard can
* track non-default Claude Code installations.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const path = require("path");
const os = require("os");
const fs = require("fs");
function getClaudeHome() {
return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
}
function getProjectsDir() {
return path.join(getClaudeHome(), "projects");
}
/**
* Canonical, user-global directory for the dashboard's writable state the
* SQLite database, VAPID keys, and transcript snapshots. It resolves to the
* SAME absolute path for every launch path (`npm start`, `npm run dev`, and the
* macOS/Windows desktop app), so they all share ONE database instead of each
* host keeping its own. Lives under the Claude home, next to the hook discovery
* file (`~/.claude/.agent-dashboard.json`).
*
* An explicit `DASHBOARD_DATA_DIR` still wins for tests, power users, or
* anyone pinning a custom location. The earlier default was the repo-local
* `data/` dir, which the desktop app (read-only bundle) couldn't use and which
* never coincided with the web server's copy; see db.js for the one-time
* migration that carries pre-existing databases into this location.
*/
function getDataDir() {
return process.env.DASHBOARD_DATA_DIR || path.join(getClaudeHome(), "agent-dashboard");
}
/**
* Dashboard-owned directory where imported transcripts are snapshotted so the
* Conversation tab survives Claude Code pruning the originals in
* ~/.claude/projects. Lives next to the SQLite DB under the shared data dir.
*/
function getTranscriptSnapshotDir() {
return path.join(getDataDir(), "transcripts");
}
function getSettingsPath() {
return path.join(getClaudeHome(), "settings.json");
}
/**
* Claude Code path encoding: replace all non-alphanumeric characters with "-".
* Example: "/Users/txj/.codefuse" "-Users-txj--codefuse"
* Note: not just "/", characters like "." are also replaced.
*/
function encodeCwd(cwd) {
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
}
/**
* Infer the main session JSONL file path from sessionId and cwd.
* Encoding rule: all non-alphanumeric characters replaced with "-".
* Falls back to scanning all project directories if the encoded path doesn't exist.
*/
function getTranscriptPath(sessionId, cwd) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const candidate = path.join(getProjectsDir(), encoded, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
// Fallback: scan projects/ subdirectories
return findTranscriptPath(sessionId);
}
/**
* Resolve a per-agent transcript file inside a session's `subagents` directory,
* supporting BOTH on-disk layouts Claude Code has used for sub-agent transcripts:
* - flat: <subagents>/agent-<agentId>.jsonl
* (regular sub-agents, and older Workflow-tool builds)
* - nested: <subagents>/workflows/<runId>/agent-<agentId>.jsonl
* (current Workflow-tool fan-out runs)
*
* The flat path is checked first, so regular sub-agents resolve exactly as
* before. For the nested layout: when `runId` is known the run directory is read
* directly; when it is unknown the nested tree is scanned and a match is
* returned ONLY if exactly one run contains that agent an ambiguous agentId
* across multiple runs resolves to null rather than guessing.
*
* @param {string} subagentsDir absolute path to a `.../subagents` directory
* @param {string} agentId the agent-<agentId>.jsonl key (no prefix/suffix)
* @param {string|null} [runId] the workflow run id, when known
* @returns {string|null} absolute transcript path, or null. Never throws.
*/
function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
if (!subagentsDir) return null;
const flat = path.join(subagentsDir, `agent-${agentId}.jsonl`);
if (fs.existsSync(flat)) return flat;
const workflowsDir = path.join(subagentsDir, "workflows");
if (!fs.existsSync(workflowsDir)) return null;
if (runId) {
const nested = path.join(workflowsDir, runId, `agent-${agentId}.jsonl`);
return fs.existsSync(nested) ? nested : null;
}
// Unknown run: accept only an unambiguous single match across all runs.
try {
const matches = [];
for (const d of fs.readdirSync(workflowsDir, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
const cand = path.join(workflowsDir, d.name, `agent-${agentId}.jsonl`);
if (fs.existsSync(cand)) matches.push(cand);
if (matches.length > 1) break;
}
return matches.length === 1 ? matches[0] : null;
} catch {
return null;
}
}
/**
* Infer the sub-agent JSONL file path from sessionId, cwd, agentId, and
* (optionally) the Workflow runId. Resolves both the flat and nested
* Workflow-tool layouts via resolveAgentTranscriptInDir. Falls back to scanning
* all project directories if the encoded path doesn't exist.
*/
function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const subagentsDir = path.join(getProjectsDir(), encoded, sessionId, "subagents");
const direct = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
if (direct) return direct;
// Fallback: scan all project directories
return findSubagentTranscriptPath(sessionId, agentId, runId);
}
/**
* When cwd is unknown, scan projects/ subdirectories to find the JSONL file for a sessionId.
* Returns the found path or null.
*/
function findTranscriptPath(sessionId) {
const projectsDir = getProjectsDir();
if (!fs.existsSync(projectsDir)) return null;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
}
} catch {
// Permission or IO error, ignore
}
return null;
}
/**
* Path to the dashboard's durable transcript snapshot for a session, if one
* exists. Snapshots are written at import time (see snapshotTranscript in
* scripts/import-history.js) so the Conversation tab keeps working after Claude
* Code deletes the original under its `cleanupPeriodDays` retention (default
* 30d). Returns the path or null.
*/
function getSnapshotTranscriptPath(sessionId) {
const candidate = path.join(getTranscriptSnapshotDir(), `${sessionId}.jsonl`);
return fs.existsSync(candidate) ? candidate : null;
}
/**
* Path to a snapshotted subagent transcript, mirroring the live layout
* `<snapshotDir>/<sessionId>/subagents/agent-<agentId>.jsonl` (flat) and
* `<snapshotDir>/<sessionId>/subagents/workflows/<runId>/agent-<agentId>.jsonl`
* (nested Workflow-tool runs, preserved by the snapshot writer). Supports the
* same compaction prefix-fuzzy match as findSubagentTranscriptPath. Returns
* the path or null.
*/
function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) {
const subDir = path.join(getTranscriptSnapshotDir(), sessionId, "subagents");
if (!fs.existsSync(subDir)) return null;
const hit = resolveAgentTranscriptInDir(subDir, agentId, runId);
if (hit) return hit;
if (agentId.startsWith("acompact-")) {
try {
const match = fs
.readdirSync(subDir)
.find((f) => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
if (match) return path.join(subDir, match);
} catch {
/* ignore */
}
}
return null;
}
/**
* Find a sub-agent JSONL file path by scanning when cwd is unknown.
* Supports both layouts (flat + nested Workflow-tool, via
* resolveAgentTranscriptInDir) and a prefix fuzzy match:
* - Exact: agent-<agentId>.jsonl (or workflows/<runId>/agent-<agentId>.jsonl)
* - Fuzzy: agent-acompact-*.jsonl (for compaction type)
*/
function findSubagentTranscriptPath(sessionId, agentId, runId = null) {
const projectsDir = getProjectsDir();
if (!fs.existsSync(projectsDir)) return null;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const subagentsDir = path.join(projectsDir, d.name, sessionId, "subagents");
if (!fs.existsSync(subagentsDir)) continue;
// Exact match (flat or nested Workflow-tool layout)
const hit = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
if (hit) return hit;
// Prefix fuzzy match (compaction type: agentId starts with "acompact-")
if (agentId.startsWith("acompact-")) {
const files = fs.readdirSync(subagentsDir);
const match = files.find((f) => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
if (match) return path.join(subagentsDir, match);
}
}
} catch {
// Ignore
}
return null;
}
/**
* Update CLAUDE_HOME at runtime. Updates process.env so getClaudeHome()
* immediately returns the new value, and persists to .env file.
* Returns the resolved absolute path.
*/
function setClaudeHome(newPath) {
const resolved = newPath.replace(/^~(?=\/)/, os.homedir());
if (!path.isAbsolute(resolved)) {
throw new Error("CLAUDE_HOME must be an absolute path");
}
if (!fs.existsSync(resolved)) {
throw new Error(`Directory does not exist: ${resolved}`);
}
const stat = fs.statSync(resolved);
if (!stat.isDirectory()) {
throw new Error(`Not a directory: ${resolved}`);
}
process.env.CLAUDE_HOME = resolved;
writeEnvFile("CLAUDE_HOME", resolved);
return resolved;
}
/**
* Write or update a key=value line in the .env file.
* Creates the file if it doesn't exist.
*/
function writeEnvFile(key, value) {
const envPath = path.resolve(__dirname, "..", "..", ".env");
let lines = [];
if (fs.existsSync(envPath)) {
lines = fs.readFileSync(envPath, "utf8").split("\n");
}
let found = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith(`${key}=`)) {
lines[i] = `${key}=${value}`;
found = true;
break;
}
}
if (!found) {
lines.push(`${key}=${value}`);
}
// Write atomically: write to temp file then rename to prevent corruption
const tempPath = envPath + ".tmp";
fs.writeFileSync(tempPath, lines.join("\n") + "\n", "utf8");
fs.renameSync(tempPath, envPath);
}
module.exports = {
getClaudeHome,
getProjectsDir,
getDataDir,
getTranscriptSnapshotDir,
getSettingsPath,
getTranscriptPath,
resolveAgentTranscriptInDir,
getSubagentTranscriptPath,
getSnapshotTranscriptPath,
getSnapshotSubagentTranscriptPath,
findTranscriptPath,
findSubagentTranscriptPath,
setClaudeHome,
writeEnvFile,
};
+160
View File
@@ -0,0 +1,160 @@
/**
* @file dashboard-runs.js
* @description Persistence layer for runs spawned via the dashboard's
* /api/run endpoint. The in-memory handle map in run-spawner.js reaps
* handles 5 min after exit, which is fine for live re-attach but loses
* historical data. This module mirrors every spawn / status transition
* into a sqlite row so the Run page can show a full history of what
* the user has spawned and resume any of those sessions.
*
* All db operations are wrapped in try/catch so a failure here can never
* take down a live run persistence is a side benefit, not a blocker.
*
* A run started through a lane also carries that lane's id (`lane_id`), so the
* Workspace page can list one lane's own run history; runs spawned straight
* from POST /api/run leave it null.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { db } = require("../db");
const PROMPT_PREVIEW_LIMIT = 500;
const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id
) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
@lane_id
)
`);
const updateStmt = db.prepare(`
UPDATE dashboard_runs
SET session_id = COALESCE(@session_id, session_id),
status = COALESCE(@status, status),
exit_code = COALESCE(@exit_code, exit_code),
ended_at = COALESCE(@ended_at, ended_at)
WHERE id = @id
`);
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code,
started_at, ended_at, lane_id`;
const listStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs
ORDER BY started_at DESC
LIMIT @limit
`);
const listByLaneStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs
WHERE lane_id = @laneId
ORDER BY started_at DESC
LIMIT @limit
`);
const getStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs WHERE id = @id
`);
/**
* Insert a new run record at spawn time. Idempotent on `id`.
*/
function recordRun(handle) {
try {
const startedAt = new Date(handle.startedAt || Date.now()).toISOString();
const endedAt = handle.endedAt ? new Date(handle.endedAt).toISOString() : null;
const prompt = typeof handle.prompt === "string" ? handle.prompt : "";
insertStmt.run({
id: handle.id,
session_id: handle.sessionId || null,
mode: handle.mode,
cwd: handle.cwd || "",
model: handle.model || null,
permission_mode: handle.permissionMode || null,
effort: handle.effort || null,
resume_session_id: handle.resumeSessionId || null,
prompt_preview: prompt.slice(0, PROMPT_PREVIEW_LIMIT) || null,
status: handle.status || "spawning",
exit_code: typeof handle.exitCode === "number" ? handle.exitCode : null,
started_at: startedAt,
ended_at: endedAt,
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
});
} catch {
/* persistence is best-effort */
}
}
/**
* Patch an existing run record. Pass null/undefined for fields you don't
* want to overwrite COALESCE in SQL leaves the existing value untouched.
*/
function patchRun({ id, sessionId, status, exitCode, endedAt }) {
try {
updateStmt.run({
id,
session_id: sessionId ?? null,
status: status ?? null,
exit_code: typeof exitCode === "number" ? exitCode : null,
ended_at: endedAt ? new Date(endedAt).toISOString() : null,
});
} catch {
/* ignore */
}
}
/** @param {{limit?: number, laneId?: number|null}} [opts] laneId narrows to one lane's runs. */
function listRuns({ limit = 50, laneId = null } = {}) {
try {
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50)));
if (laneId != null) return listByLaneStmt.all({ limit: safeLimit, laneId });
return listStmt.all({ limit: safeLimit });
} catch {
return [];
}
}
function getRun(id) {
try {
return getStmt.get({ id }) || null;
} catch {
return null;
}
}
const reconcileStmt = db.prepare(`
UPDATE dashboard_runs
SET status = 'abandoned',
ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE status IN ('running', 'spawning')
`);
/**
* On server boot, any rows still flagged `running` or `spawning` are
* orphans the spawner only persists those statuses for handles it knows
* about, and the in-memory map was just wiped by the restart. Mark them as
* `abandoned` so the UI doesn't display them as live and the user can
* resume them like any other completed past run.
*
* Returns the number of rows updated.
*/
function reconcileOrphans() {
try {
const info = reconcileStmt.run();
return info.changes || 0;
} catch {
return 0;
}
}
module.exports = { recordRun, patchRun, listRuns, getRun, reconcileOrphans };
+255
View File
@@ -0,0 +1,255 @@
/**
* @file server/lib/data-transfer.js
* @description Full-dataset export/import ("backup / restore") for the local
* dashboard database. This is the round-trip counterpart to the transcript
* importer (scripts/import-history.js): where that reconstructs sessions from
* raw Claude Code JSONL, this serializes the dashboard's OWN captured data to a
* single portable JSON bundle and restores it later the workflow a user needs
* to consolidate several machines into one dashboard.
*
* Design guarantees:
* Complete the bundle carries every table that holds user-owned captured
* data or portable configuration: sessions, agents, events, token_usage,
* workflows, dashboard_runs, alert_rules, and model_pricing. Machine-bound
* or secret-bearing tables (push_subscriptions, webhook_targets/deliveries,
* alert_events audit log) are intentionally excluded.
* Idempotent + non-destructive restore is session-atomic: a session that
* already exists (matched by its stable UUID) is skipped WHOLE, together
* with its agents/events/token_usage/workflows, so re-importing the same
* bundle (or overlapping bundles from two machines) never duplicates rows
* or clobbers live data. Independent config rows (dashboard_runs,
* alert_rules, model_pricing) are inserted with INSERT OR IGNORE on their
* natural primary key.
* Accurate token_usage (including compaction baselines) is restored
* verbatim for every new session, so cost/analytics match the source
* machine exactly. events are re-inserted WITHOUT their source autoincrement
* id (which is not portable across databases); SQLite assigns fresh ids.
* Schema-tolerant inserts are built by intersecting each table's live
* columns (PRAGMA table_info) with the keys present on each row, so older
* or newer bundles import cleanly without a migration step.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
"use strict";
const EXPORT_FORMAT = "ccam-export";
const EXPORT_VERSION = 1;
// Tables serialized into the bundle. Order matters for restore (parents before
// children); FK checks are deferred to COMMIT anyway (see importExportBundle).
const SESSION_CHILD_TABLES = ["agents", "events", "token_usage", "workflows"];
/**
* Build the full export bundle from the live database.
*
* @param {import("better-sqlite3").Database} db
* @param {{ listPricing: { all: () => any[] } }} stmts - the prepared-statement
* bag from server/db.js (used for the canonical model_pricing ordering).
* @returns {object} A JSON-serializable bundle stamped with format/version.
*/
function buildExportBundle(db, stmts) {
return {
format: EXPORT_FORMAT,
version: EXPORT_VERSION,
exported_at: new Date().toISOString(),
sessions: db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all(),
agents: db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all(),
events: db.prepare("SELECT * FROM events ORDER BY created_at DESC").all(),
token_usage: db.prepare("SELECT * FROM token_usage").all(),
workflows: db.prepare("SELECT * FROM workflows").all(),
dashboard_runs: db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all(),
alert_rules: db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all(),
model_pricing: stmts.listPricing.all(),
};
}
/** Column names of a table, in definition order. */
function tableColumns(db, table) {
return db
.prepare(`PRAGMA table_info(${table})`)
.all()
.map((c) => c.name);
}
// better-sqlite3 only binds numbers/strings/bigints/buffers/null. A row parsed
// from JSON never contains booleans/objects for these tables (SQLite stores
// them as INTEGER/TEXT), but a missing key yields `undefined`, which throws —
// normalize it to null so partial/legacy rows still bind.
function bindable(v) {
if (v === undefined) return null;
if (typeof v === "boolean") return v ? 1 : 0;
return v;
}
/**
* Make a prepared INSERT OR IGNORE that only writes the columns a table
* actually has AND the row actually provides. `omit` drops columns even if
* present (used to strip the non-portable events.id).
*/
function makeInserter(db, table, { omit = [] } = {}) {
const cols = tableColumns(db, table).filter((c) => !omit.includes(c));
const quoted = cols.map((c) => `"${c}"`).join(", ");
const placeholders = cols.map(() => "?").join(", ");
const stmt = db.prepare(`INSERT OR IGNORE INTO ${table} (${quoted}) VALUES (${placeholders})`);
return (row) => stmt.run(cols.map((c) => bindable(row[c])));
}
/** Group an array of rows by a key field into a Map. */
function groupBy(rows, key) {
const map = new Map();
for (const r of Array.isArray(rows) ? rows : []) {
if (!r || r[key] == null) continue;
const k = r[key];
if (!map.has(k)) map.set(k, []);
map.get(k).push(r);
}
return map;
}
class ImportFormatError extends Error {
constructor(message) {
super(message);
this.name = "ImportFormatError";
this.code = "INVALID_FORMAT";
}
}
/**
* Validate a parsed object looks like an export bundle. Accepts bundles stamped
* with our format marker AND legacy exports (pre-versioning) that merely carry
* a `sessions` array, so old backups remain importable.
*
* @throws {ImportFormatError}
*/
function assertBundle(bundle) {
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
throw new ImportFormatError("Not a valid export file (expected a JSON object).");
}
if (bundle.format && bundle.format !== EXPORT_FORMAT) {
throw new ImportFormatError(
`Unrecognized export format "${bundle.format}" (expected "${EXPORT_FORMAT}").`
);
}
const hasAnyTable =
Array.isArray(bundle.sessions) ||
Array.isArray(bundle.model_pricing) ||
Array.isArray(bundle.alert_rules) ||
Array.isArray(bundle.dashboard_runs);
if (!bundle.format && !hasAnyTable) {
throw new ImportFormatError(
"Not a recognizable dashboard export (no sessions/pricing/rules arrays)."
);
}
}
/**
* Restore an export bundle into the live database. Idempotent and
* non-destructive (see file header). Runs inside a single transaction with
* deferred FK checks so agent parent/child ordering never trips a constraint.
*
* @param {import("better-sqlite3").Database} db
* @param {object} bundle - parsed export JSON.
* @returns {{sessions_imported:number, sessions_skipped:number, agents:number,
* events:number, token_usage:number, workflows:number, dashboard_runs:number,
* alert_rules:number, model_pricing:number, errors:number}}
*/
function importExportBundle(db, bundle) {
assertBundle(bundle);
const counters = {
sessions_imported: 0,
sessions_skipped: 0,
agents: 0,
events: 0,
token_usage: 0,
workflows: 0,
dashboard_runs: 0,
alert_rules: 0,
model_pricing: 0,
errors: 0,
};
const sessionExists = db.prepare("SELECT 1 FROM sessions WHERE id = ?").pluck();
const insert = {
sessions: makeInserter(db, "sessions"),
agents: makeInserter(db, "agents"),
events: makeInserter(db, "events", { omit: ["id"] }),
token_usage: makeInserter(db, "token_usage"),
workflows: makeInserter(db, "workflows"),
dashboard_runs: makeInserter(db, "dashboard_runs"),
alert_rules: makeInserter(db, "alert_rules"),
model_pricing: makeInserter(db, "model_pricing"),
};
const childRows = {
agents: groupBy(bundle.agents, "session_id"),
events: groupBy(bundle.events, "session_id"),
token_usage: groupBy(bundle.token_usage, "session_id"),
workflows: groupBy(bundle.workflows, "session_id"),
};
const sessions = Array.isArray(bundle.sessions) ? bundle.sessions : [];
const run = db.transaction(() => {
// Defer FK enforcement to COMMIT: an agent's parent_agent_id may point to a
// sibling that is inserted later in the same batch. Auto-resets at COMMIT.
db.pragma("defer_foreign_keys = ON");
for (const s of sessions) {
if (!s || !s.id) {
counters.errors++;
continue;
}
if (sessionExists.get(s.id)) {
counters.sessions_skipped++;
continue;
}
insert.sessions(s);
counters.sessions_imported++;
// Agents first so events/token_usage that reference them satisfy FKs.
for (const a of childRows.agents.get(s.id) || []) {
if (insert.agents(a).changes > 0) counters.agents++;
}
// Insert events oldest-first so fresh autoincrement ids stay chronological.
const evs = (childRows.events.get(s.id) || [])
.slice()
.sort((a, b) => String(a.created_at || "").localeCompare(String(b.created_at || "")));
for (const e of evs) {
if (insert.events(e).changes > 0) counters.events++;
}
for (const tu of childRows.token_usage.get(s.id) || []) {
if (insert.token_usage(tu).changes > 0) counters.token_usage++;
}
for (const wf of childRows.workflows.get(s.id) || []) {
if (insert.workflows(wf).changes > 0) counters.workflows++;
}
}
// Session-independent, config-like tables: restore by natural PK, never
// overwriting a row the target machine already has.
for (const r of Array.isArray(bundle.dashboard_runs) ? bundle.dashboard_runs : []) {
if (r && r.id && insert.dashboard_runs(r).changes > 0) counters.dashboard_runs++;
}
for (const r of Array.isArray(bundle.alert_rules) ? bundle.alert_rules : []) {
if (r && r.id && insert.alert_rules(r).changes > 0) counters.alert_rules++;
}
for (const p of Array.isArray(bundle.model_pricing) ? bundle.model_pricing : []) {
if (p && p.model_pattern && insert.model_pricing(p).changes > 0) counters.model_pricing++;
}
});
run();
return counters;
}
module.exports = {
EXPORT_FORMAT,
EXPORT_VERSION,
SESSION_CHILD_TABLES,
buildExportBundle,
importExportBundle,
ImportFormatError,
};
+40
View File
@@ -0,0 +1,40 @@
/**
* @file Per-lane serialization lock. Ensures that only one async operation runs
* on a lane at a time, preventing concurrent git checkout races and other
* worktree collisions. Implemented as a chain of promises keyed by lane ID;
* acquiring a lock waits for the previous holder to settle (success or throw),
* then runs the new work, and releases for the next waiter.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const chains = new Map();
/**
* Acquire the per-lane lock, run fn, and release.
* The lock serialises work per lane_id: the next waiter runs only after the
* previous holder settles (success or error). If fn throws, the exception
* propagates to the caller; the chain is *not* poisoned the next waiter
* still gets a fresh attempt.
*
* @param {number|string} id - Lane ID, converted to string for deduplication.
* @param {() => Promise<T>} fn - Async function to run while holding the lock.
* @returns {Promise<T>} The result of fn, or its thrown error.
*/
function withLaneLock(id, fn) {
const key = String(id);
const prev = chains.get(key) || Promise.resolve();
const run = prev.then(fn, fn); // run regardless of how the previous holder settled
// Keep the chain alive but never let a rejection poison the next waiter.
const settled = run.then(
() => {},
() => {}
);
chains.set(key, settled);
// Clean up the chain entry when it settles to prevent unbounded map growth.
settled.then(() => {
if (chains.get(key) === settled) chains.delete(key);
});
return run;
}
module.exports = { withLaneLock };
+150
View File
@@ -0,0 +1,150 @@
/**
* @file Preflight checks before destructive lane operations (reset, remove, purge).
* Queries the current state without mutations: git status, unpushed commits, sessions
* to be purged, and blockers (adopted lanes, missing directories, unpushed work).
* Every result is read-only; the route and action layer decide what to do with blocks.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const { db } = require("../db");
const wt = require("./worktree");
const lanesLib = require("./lanes");
/**
* Preflight for reset, remove, or purge. Returns an object describing what will happen:
* - reset/remove: {action, lane, kind, branch, dirty, untracked, unpushed, head, blocked, warnings}
* - purge: {action, lane, sessions, events, tokenRows, bytesEstimate, activeSessionSkipped}
*
* blocked[] holds only conditions that genuinely prevent the action: "adopted" (not
* managed), "missing" (dir gone), "unreadable" (git failed against the directory), and
* "unpushed-commits" (unpushed > 0 the only one a `force: true` overrides). warnings[]
* holds purely informational facts that never gate the action, starting with "no-remote"
* (no git remote configured nothing is backed up, but the action proceeds). Both are
* data, not an exception.
*
* @param {object} lane - The lane to check
* @param {string} action - One of "reset", "remove", "purge"
* @returns {Promise<object>} Preflight report
*/
async function preflight(lane, action) {
const blocked = [];
const warnings = [];
// Check if lane is adopted (not managed)
if (lane.kind === "adopted") {
blocked.push("adopted");
}
// For reset/remove, return git status and blockers
if (action === "reset" || action === "remove") {
let dirty = 0;
let untracked = 0;
let unpushed = 0;
let head = null;
// First check if directory exists
if (!fs.existsSync(lane.cwd)) {
blocked.push("missing");
} else {
// Directory exists, try to read git status
try {
// Get status counts
const status = await wt.statusCounts(lane.cwd);
dirty = status.dirty;
untracked = status.untracked;
head = status.head;
// Get unpushed count — measured against the lane's own base branch when
// there is no remote, so it counts this lane's work, not the whole repo.
unpushed = await wt.unpushedCount(lane.cwd, lane.base_branch);
if (unpushed > 0) {
blocked.push("unpushed-commits");
}
// Detect no remotes configured at all — informational only, never a blocker:
// a perfectly ordinary local-only managed lane has no remote at all.
const noRemote = await wt.hasNoRemotes(lane.cwd);
if (noRemote) {
warnings.push("no-remote");
}
} catch (err) {
// Directory exists but git failed (corrupt repo, permission denied, etc.)
blocked.push("unreadable");
}
}
return {
action,
lane: lane.id,
kind: lane.kind,
branch: lane.branch,
dirty,
untracked,
unpushed,
head: head || null,
blocked,
warnings,
};
}
// For purge, count sessions and events to be deleted
if (action === "purge") {
const counts = countPurgeSessions(lane);
return {
action,
lane: lane.id,
sessions: counts.sessions,
events: counts.events,
tokenRows: counts.tokenRows,
bytesEstimate: (counts.events + counts.tokenRows) * 512,
activeSessionSkipped: counts.activeSessionSkipped,
};
}
// Unknown action should never reach here (route validates)
throw Object.assign(new Error(`unknown action: ${action}`), { code: "EBADACTION" });
}
/**
* Count sessions, events, and token_usage rows that would be deleted by purgeLaneSessions.
* Uses the shared purgeCandidateSessions helper to ensure the counts match exactly what
* gets deleted, so the confirmation dialog's numbers are truthful.
*/
function countPurgeSessions(lane) {
const result = { sessions: 0, events: 0, tokenRows: 0, activeSessionSkipped: false };
// Use the shared helpers so the path matching (and its LIKE escaping) has
// exactly one definition, in server/lib/lanes.js.
const sessionsToDelete = lanesLib.purgeCandidateSessions(lane);
result.activeSessionSkipped = lanesLib.hasActiveLaneSession(lane);
if (sessionsToDelete.length === 0) {
return result;
}
// Count events for those sessions
const sessionIds = sessionsToDelete.map((s) => s.id);
const eventsCount = db
.prepare(
`SELECT COUNT(*) as count FROM events WHERE session_id IN (${sessionIds.map(() => "?").join(",")})`
)
.get(...sessionIds);
result.events = eventsCount ? eventsCount.count : 0;
// Count orphaned token_usage rows
const tokenCount = db
.prepare(
`SELECT COUNT(*) as count FROM token_usage WHERE session_id IN (${sessionIds.map(() => "?").join(",")})`
)
.get(...sessionIds);
result.tokenRows = tokenCount ? tokenCount.count : 0;
// Count sessions (for completeness)
result.sessions = sessionsToDelete.length;
return result;
}
module.exports = { preflight };
+461
View File
@@ -0,0 +1,461 @@
/**
* @file Lane storage and lifecycle. A lane is a durable unit of parallel agent
* work one working directory, many sessions over time so the dashboard can
* show a pipeline that survives session restarts. This module owns every SQL
* statement touching the `lanes` table, resolves an incoming hook's `cwd` onto a
* lane, records stage transitions (with `stage_since` semantics), and classifies
* liveness the way Shipyard does: a silent watcher is dead, a silent idle lane
* is merely at rest.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { db } = require("../db");
const { getPipeline, phaseIdx, nodeStates, progressPct } = require("./pipelines");
const DEAD_SEC = Number(process.env.LANE_DEAD_SEC || 300);
/** How long a detection holds the forward-only floor. Read per call, not once
* at load, so a test (and an operator restarting nothing) can change it. */
function detectionTtlMs() {
const raw = Number(process.env.DETECTION_TTL_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 1_800_000;
}
/** True when `iso` is absent, unparseable, or older than the TTL. An unknown
* age cannot be proven fresh, so it counts as stale. */
function detectionIsStale(iso) {
if (!iso) return true;
const at = Date.parse(iso);
if (!Number.isFinite(at)) return true;
return Date.now() - at > detectionTtlMs();
}
/** Stages whose whole job is to wait — silence here means the loop died. */
const WATCH_STAGE_RE = /watch|poll/i;
/**
* Fields a client may change through `PATCH /api/lanes/:id`.
*
* `kind`, `source_repo`, `slug` and `base_branch` are deliberately ABSENT: they
* are provisioning-time facts, and `kind` is check 1 of the destroy guard. A
* client that could flip `kind` to "managed" at runtime could point the guard at
* a directory the user owns. Provisioning writes them through
* setProvisioningFacts instead.
*/
const PATCHABLE = new Set([
"title",
"branch",
"pipeline",
"status",
"gate_decision",
"ci_status",
"needs_action",
"links",
"notes",
"session_id",
"run_id",
]);
/** Provisioning-time facts, writable only by this module's internal setter. */
const PROVISIONING_FIELDS = new Set(["kind", "source_repo", "base_branch", "slug"]);
const nowIso = () => new Date().toISOString();
const VALID_KINDS = new Set(["adopted", "managed"]);
function validateKind(kind) {
if (!VALID_KINDS.has(kind)) {
throw Object.assign(new Error(`unknown kind: ${kind}`), { code: "EBADKIND" });
}
}
function hydrate(row) {
if (!row) return null;
let stages = {};
let links = {};
try {
stages = JSON.parse(row.stages || "{}");
} catch {
/* corrupt blob -> empty */
}
try {
links = JSON.parse(row.links || "{}");
} catch {
/* corrupt blob -> empty */
}
return { ...row, stages, links };
}
function createLane({
title = "",
cwd,
branch = null,
pipeline = "default",
kind = "adopted",
source_repo = null,
base_branch = null,
slug = null,
} = {}) {
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
}
validateKind(kind);
const info = db
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run(
title,
cwd.replace(/\/+$/, ""),
branch,
pipeline,
kind,
source_repo,
base_branch,
slug,
nowIso()
);
return getLane(info.lastInsertRowid);
}
function listLanes() {
return db.prepare("SELECT * FROM lanes ORDER BY id ASC").all().map(hydrate);
}
function getLane(id) {
return hydrate(db.prepare("SELECT * FROM lanes WHERE id = ?").get(id));
}
function updateLane(id, patch = {}) {
// Validate kind before building the UPDATE if it's being set
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
validateKind(patch.kind);
}
const cols = [];
const vals = [];
for (const [k, v] of Object.entries(patch)) {
if (!PATCHABLE.has(k)) continue;
cols.push(`${k} = ?`);
vals.push(k === "links" && typeof v === "object" ? JSON.stringify(v) : v);
}
if (cols.length) {
cols.push("updated_at = ?");
vals.push(nowIso(), id);
db.prepare(`UPDATE lanes SET ${cols.join(", ")} WHERE id = ?`).run(...vals);
}
return getLane(id);
}
/**
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach
* today only `base_branch`, resolved after `git worktree add` succeeds. Server
* -internal: no route passes user input here.
*
* @param {number} id - The lane id.
* @param {object} facts - Subset of PROVISIONING_FIELDS to write.
*/
function setProvisioningFacts(id, facts = {}) {
const cols = [];
const vals = [];
for (const [k, v] of Object.entries(facts)) {
if (!PROVISIONING_FIELDS.has(k)) continue;
if (k === "kind") validateKind(v);
cols.push(`${k} = ?`);
vals.push(v);
}
if (cols.length) {
cols.push("updated_at = ?");
vals.push(nowIso(), id);
db.prepare(`UPDATE lanes SET ${cols.join(", ")} WHERE id = ?`).run(...vals);
}
return getLane(id);
}
function deleteLane(id) {
return db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
}
/**
* Record a stage transition. `stage_since` moves ONLY when the stage value
* actually changes, so the UI's time-on-phase is real; a re-report of the same
* stage (a heartbeat, an added note) leaves it alone.
*/
function setStage(id, { stage, status, evidence, note, result } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const next = stage || lane.stage;
const stages = { ...lane.stages };
const prev = stages[next] || {};
stages[next] = {
enteredAt: next === lane.stage && prev.enteredAt ? prev.enteredAt : nowIso(),
evidence: evidence !== undefined ? evidence : prev.evidence || null,
result: result !== undefined ? result : prev.result || null,
};
db.prepare(
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?
WHERE id = ?`
).run(
next,
next === lane.stage ? lane.stage_since || nowIso() : nowIso(),
status || lane.status,
JSON.stringify(stages),
note !== undefined ? note : lane.notes,
nowIso(),
id
);
return getLane(id);
}
/**
* Record an inferred stage from the hook stream. Inference is never evidence
* this writes only `detected_stage`/`detected_signal`/`detected_at`, never
* `stage` (the declared stage), so a lane's declared meaning never changes.
*
* Writes only when BOTH hold:
* - forward-only: the detection's node index is strictly greater than the
* current `detected_stage`'s index (reading a file after editing it must
* not drag a lane back to `plan`);
* - declared wins: the lane's DECLARED stage index is strictly less than
* the detection's (a lane already declared at `review` ignores an
* `implement` detection).
* Otherwise touches nothing and reports why: `behind-detected`,
* `behind-declared`, or `unknown-node`.
*
* @param {number} id - The lane id.
* @param {{nodeId: string, signal: string}} detection - From stage-detect.detect().
* @returns {{written: boolean, reason?: string}}
*/
function recordDetection(id, { nodeId, signal } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const pipeline = getPipeline(lane.pipeline);
const nodeIdx = phaseIdx(pipeline, nodeId);
if (nodeIdx === -1) return { written: false, reason: "unknown-node" };
// Forward-only holds only while the standing detection is fresh. Once it has
// aged past the TTL the agent has almost certainly moved on to different
// work, so a stale `ship` must not pin the lane forever. Declared-wins below
// is NOT relaxed by staleness - an agent's own claim never expires.
const detectedIdx = detectionIsStale(lane.detected_at)
? -1
: phaseIdx(pipeline, lane.detected_stage);
if (nodeIdx <= detectedIdx) return { written: false, reason: "behind-detected" };
const declaredIdx = phaseIdx(pipeline, lane.stage);
if (declaredIdx >= nodeIdx) return { written: false, reason: "behind-declared" };
db.prepare(
"UPDATE lanes SET detected_stage = ?, detected_signal = ?, detected_at = ? WHERE id = ?"
).run(nodeId, signal || null, nowIso(), id);
return { written: true };
}
/**
* Reset a lane to a blank slate. The detection columns are cleared with the
* declared ones on purpose: a kept `detected_stage` would both paint inferred
* progress for a tree where nothing has happened AND permanently kill detection
* for that lane, because recordDetection is forward-only a stale `ship` can
* never be advanced past.
*/
function clearLane(id) {
db.prepare(
`UPDATE lanes SET stage = 'idle', stage_since = ?, status = 'idle', gate_decision = NULL,
ci_status = NULL, needs_action = NULL, stages = '{}', notes = NULL, run_id = NULL,
detected_stage = NULL, detected_signal = NULL, detected_at = NULL,
updated_at = ? WHERE id = ?`
).run(nowIso(), nowIso(), id);
return getLane(id);
}
/**
* A provisioning task exists only in the server process that created it. On a
* new boot, any lane still marked provisioning was interrupted before it could
* report a terminal result, so expose it as a removable failure instead.
*
* @returns {number} Number of interrupted lanes recovered.
*/
function recoverInterruptedProvisioning() {
return db
.prepare(
"UPDATE lanes SET status = 'failed', notes = ?, updated_at = ? WHERE status = 'provisioning'"
)
.run("Provisioning was interrupted by a server restart.", nowIso()).changes;
}
/**
* Longest path-boundary prefix match. `/tmp/wt` must NOT capture
* `/tmp/wt-sibling`, and a nested lane must beat its parent.
*/
function resolveLaneByCwd(cwd) {
if (!cwd || typeof cwd !== "string") return null;
const target = cwd.replace(/\/+$/, "");
let best = null;
for (const lane of listLanes()) {
const base = lane.cwd.replace(/\/+$/, "");
if (target === base || target.startsWith(`${base}/`)) {
if (!best || base.length > best.cwd.length) best = lane;
}
}
return best;
}
function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
const expectLive =
status === "running" || status === "provisioning" || WATCH_STAGE_RE.test(stage || "");
if (!expectLive) return "idle";
if (ageSec !== null && ageSec !== undefined && ageSec > deadSec) return "dead";
return "active";
}
/**
* Annotate nodeStates() with `detected: boolean` true for the detected node
* itself and for any node before it that carries no declaration. Never flips
* a node to `done`: detection only ever adds this flag alongside whatever
* state nodeStates() already computed from the declared stage, which is the
* only path to `done`.
*
* The `current` node is never flagged, even when it has no `stages` entry under
* its own id: declaring by ALIAS (`ccam stage coding` the `implement` node)
* keys `stages` by the raw declared string, so the node the agent says it is on
* would otherwise render as an inference instead of the blue `current` ring.
*/
function withDetected(states, pipeline, lane) {
const detectedIdx = phaseIdx(pipeline, lane.detected_stage);
if (detectedIdx === -1) return states.map((n) => ({ ...n, detected: false }));
const stages = lane.stages || {};
return states.map((n, i) => ({
...n,
detected: i <= detectedIdx && !stages[n.id] && n.state !== "current",
}));
}
function lanePayload(lane, ageSec = null) {
const pipeline = getPipeline(lane.pipeline);
const since = lane.stage_since ? Date.parse(lane.stage_since) : NaN;
return {
...lane,
pipeline_name: pipeline.name,
pipeline_nodes: withDetected(nodeStates(pipeline, lane), pipeline, lane),
progress: progressPct(pipeline, lane),
stage_seconds: Number.isNaN(since)
? null
: Math.max(0, Math.round((Date.now() - since) / 1000)),
last_event_seconds: ageSec,
liveness: classifyLiveness({ status: lane.status, stage: lane.stage, ageSec }, DEAD_SEC),
};
}
/**
* Build the LIKE pattern matching a lane's subdirectories, escaping the
* characters LIKE treats as wildcards.
*
* CRITICAL: `_` is a single-character wildcard, and every managed lane directory
* is named `<repo>__<slug>` two literal underscores. Unescaped, a lane at
* `/root/myrepo__feat-foo` also matched `/root/myrepoXXfeat-foo`, so a purge
* deleted a sibling directory's sessions and the preflight count reported the
* victims too: the confirmation was consistently wrong rather than detectably
* wrong. `\` and `%` are escaped for the same reason.
*/
const SUBDIR_LIKE_ESCAPE = "\\";
function subdirLikePattern(cwd) {
return `${cwd.replace(/[\\%_]/g, `${SUBDIR_LIKE_ESCAPE}$&`)}/%`;
}
/**
* Find sessions that belong to a lane and may be purged: exact or subdirectory,
* excluding the lane's bound session and any active sessions. Shared between
* purgeLaneSessions (the deleter) and preflight counting, so the confirmation
* dialog's numbers match what actually gets deleted.
*/
function purgeCandidateSessions(lane) {
return db
.prepare(
`SELECT id FROM sessions
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
AND id != ?
AND status != 'active'`
)
.all(lane.cwd, subdirLikePattern(lane.cwd), lane.session_id || "");
}
/**
* True when a lane owns at least one still-active session, which purge always
* spares. Lives here beside purgeCandidateSessions so both derive their path
* matching from the one escaped helper preflight used to hand-write this
* clause and inherited the unescaped-`_` bug with it.
*/
function hasActiveLaneSession(lane) {
const row = db
.prepare(
`SELECT COUNT(*) AS count FROM sessions
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
AND id != ?
AND status = 'active'`
)
.get(lane.cwd, subdirLikePattern(lane.cwd), lane.session_id || "");
return Boolean(row && row.count > 0);
}
/**
* Delete all sessions associated with a lane, except the one bound to the lane
* itself (lanes.session_id) and any active sessions. Deletes their events and
* orphaned token_usage rows explicitly (token_usage has no FK to cascade).
* Runs in a single transaction; on completion, runs db.pragma("optimize")
* to update query statistics (never VACUUM, which locks the database).
*
* @param {number} laneId - The lane ID.
* @returns {{sessions: number, events: number, tokenRows: number}} Count of deleted rows.
*/
function purgeLaneSessions(laneId) {
const lane = getLane(laneId);
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
const result = { sessions: 0, events: 0, tokenRows: 0 };
db.transaction(() => {
// Select sessions matching the lane's cwd (exact or subdir), excluding the
// lane's bound session and any active sessions.
const sessionsToDelete = purgeCandidateSessions(lane);
// Delete events for those sessions
result.events = db
.prepare(
`DELETE FROM events WHERE session_id IN (${sessionsToDelete.map(() => "?").join(",")})`
)
.run(...sessionsToDelete.map((s) => s.id)).changes;
// Delete orphaned token_usage rows (token_usage has no FK, so it won't cascade)
result.tokenRows = db
.prepare(
`DELETE FROM token_usage WHERE session_id IN (${sessionsToDelete.map(() => "?").join(",")})`
)
.run(...sessionsToDelete.map((s) => s.id)).changes;
// Delete the sessions themselves
result.sessions = db
.prepare(`DELETE FROM sessions WHERE id IN (${sessionsToDelete.map(() => "?").join(",")})`)
.run(...sessionsToDelete.map((s) => s.id)).changes;
})();
db.pragma("optimize");
return result;
}
module.exports = {
DEAD_SEC,
createLane,
listLanes,
getLane,
updateLane,
deleteLane,
setStage,
recordDetection,
clearLane,
recoverInterruptedProvisioning,
resolveLaneByCwd,
classifyLiveness,
lanePayload,
purgeCandidateSessions,
hasActiveLaneSession,
purgeLaneSessions,
setProvisioningFacts,
};
+117
View File
@@ -0,0 +1,117 @@
/**
* @file Pipeline templates for lanes. A template is a plain JSON list of nodes
* (`server/data/pipelines/*.json` plus any override dropped in
* `DASHBOARD_PIPELINES_DIR`); this module resolves a lane's declared stage onto
* a node through per-node `aliases`, and derives the five render states the
* pipeline map draws. Pure functions no DB, no I/O beyond the one-time
* template load, so it stays trivially testable.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const DEFAULT_PIPELINE_ID = "default";
const BUILTIN_DIR = path.join(__dirname, "..", "data", "pipelines");
/** Load every template once. A malformed file is skipped, never fatal. */
function loadAll() {
const dirs = [BUILTIN_DIR];
if (process.env.DASHBOARD_PIPELINES_DIR) dirs.push(process.env.DASHBOARD_PIPELINES_DIR);
const out = new Map();
for (const dir of dirs) {
let files = [];
try {
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
} catch {
continue; // dir absent — fine
}
for (const f of files) {
try {
const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue;
doc.nodes = doc.nodes.map((n) => ({
id: n.id,
label: n.label || n.id,
icon: n.icon || "",
gate: !!n.gate,
aliases: Array.isArray(n.aliases) ? n.aliases : [],
detect: Array.isArray(n.detect) ? n.detect : [],
}));
out.set(doc.id, doc); // later dir wins — user override beats builtin
} catch {
/* skip malformed template */
}
}
}
return out;
}
let cache = null;
function templates() {
if (!cache) cache = loadAll();
return cache;
}
/** Test/dev helper: forget the cached templates so a new file is picked up. */
function reload() {
cache = null;
}
function listPipelines() {
return [...templates().values()];
}
/** Never throws: an unknown id yields the default template. */
function getPipeline(id) {
const t = templates();
return t.get(id) || t.get(DEFAULT_PIPELINE_ID);
}
/** Index of the node matching `stage` by id or alias; -1 when unknown. */
function phaseIdx(pipeline, stage) {
if (!stage) return -1;
const s = String(stage).toLowerCase();
return pipeline.nodes.findIndex(
(n) => n.id.toLowerCase() === s || n.aliases.some((a) => a.toLowerCase() === s)
);
}
/**
* Render state per node:
* failed the stage recorded result "fail"
* current the lane's current stage
* done recorded AND carries evidence (an artifact, not a claim)
* passed-no-evidence recorded without evidence, or implicitly skipped past
* pending not reached
*/
function nodeStates(pipeline, lane) {
const stages = lane.stages || {};
const cur = phaseIdx(pipeline, lane.stage);
return pipeline.nodes.map((n, i) => {
const rec = stages[n.id];
let state;
if (rec && rec.result === "fail") state = "failed";
else if (i === cur) state = "current";
else if (rec) state = rec.evidence ? "done" : "passed-no-evidence";
else if (cur > -1 && i < cur) state = "passed-no-evidence";
else state = "pending";
return { id: n.id, label: n.label, icon: n.icon, gate: n.gate, state };
});
}
function progressPct(pipeline, lane) {
const i = phaseIdx(pipeline, lane.stage);
if (i < 0) return 0;
return Math.round((i / (pipeline.nodes.length - 1)) * 100);
}
module.exports = {
DEFAULT_PIPELINE_ID,
listPipelines,
getPipeline,
phaseIdx,
nodeStates,
progressPct,
reload,
};
+57
View File
@@ -0,0 +1,57 @@
/**
* @file Feature-level pricing constants and modifier math, centralized so the
* cost calculator stays readable and every rate has one source of truth. These
* mirror Anthropic's published pricing page. Per-model token rates live in the
* editable `model_pricing` table; the values here are feature/modifier rates
* that are uniform across models and therefore kept as code constants.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
// ── Prompt-caching multipliers (relative to base input price) ───────────────
// Stored model rates already encode these for the standard tier, but fast-mode
// cache rates are derived from the fast input base using the same ratios, so we
// keep the multipliers here for that derivation and for documentation.
const CACHE_READ_MULTIPLIER = 0.1; // cache hit / refresh
const CACHE_WRITE_5M_MULTIPLIER = 1.25; // 5-minute ephemeral write
const CACHE_WRITE_1H_MULTIPLIER = 2.0; // 1-hour ephemeral write
// ── Cross-cutting rate modifiers ────────────────────────────────────────────
const DATA_RESIDENCY_US_MULTIPLIER = 1.1; // inference_geo === "us"
const BATCH_DISCOUNT_MULTIPLIER = 0.5; // service_tier === "batch" (50% off)
// ── Server-tool surcharges (billed in addition to tokens) ───────────────────
const WEB_SEARCH_PER_1K_SEARCHES = 10.0; // $10 per 1,000 web_search_requests
const WEB_FETCH_PER_REQUEST = 0.0; // web fetch has no surcharge — tokens only
// Code execution: billed by container-time, not request count. Transcripts only
// expose request counts, so we estimate at the documented 5-minute minimum per
// request. It is FREE when the same request also used web search or web fetch.
// Each org gets a monthly free allowance; below it, code execution costs $0.
const CODE_EXEC_PER_HOUR = 0.05; // $0.05 per container-hour beyond the free tier
const CODE_EXEC_MIN_MINUTES = 5; // 5-minute minimum billed per request
const CODE_EXEC_FREE_HOURS = 1550; // free hours per org per month
/**
* Estimated billable code-execution hours for a bucket.
* Returns 0 when the bucket also used web search or web fetch (code execution is
* free in that case) or when there were no code-execution requests.
*/
function estimateCodeExecHours(codeExecRequests, webSearchRequests, webFetchRequests) {
if (!codeExecRequests || codeExecRequests <= 0) return 0;
if ((webSearchRequests || 0) > 0 || (webFetchRequests || 0) > 0) return 0; // free with search/fetch
return (codeExecRequests * CODE_EXEC_MIN_MINUTES) / 60;
}
module.exports = {
CACHE_READ_MULTIPLIER,
CACHE_WRITE_5M_MULTIPLIER,
CACHE_WRITE_1H_MULTIPLIER,
DATA_RESIDENCY_US_MULTIPLIER,
BATCH_DISCOUNT_MULTIPLIER,
WEB_SEARCH_PER_1K_SEARCHES,
WEB_FETCH_PER_REQUEST,
CODE_EXEC_PER_HOUR,
CODE_EXEC_MIN_MINUTES,
CODE_EXEC_FREE_HOURS,
estimateCodeExecHours,
};
+132
View File
@@ -0,0 +1,132 @@
/**
* @file Handles web push notifications using the `web-push` library, including generating/loading VAPID keys, sending notifications to all subscribed clients, and cleaning up invalid subscriptions. It provides a function to retrieve the public VAPID key for client registration and a function to broadcast notifications to all subscribers stored in the database.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const webpush = require("web-push");
const path = require("path");
const fs = require("fs");
const { getDataDir } = require("./claude-home");
// Lives in the shared data dir alongside the SQLite DB (see getDataDir), so the
// web app and the native apps reuse one set of VAPID keys.
const KEYS_PATH = path.join(getDataDir(), "vapid-keys.json");
function loadOrCreateVapidKeys() {
if (fs.existsSync(KEYS_PATH)) {
return JSON.parse(fs.readFileSync(KEYS_PATH, "utf8"));
}
const keys = webpush.generateVAPIDKeys();
fs.mkdirSync(path.dirname(KEYS_PATH), { recursive: true });
fs.writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2));
return keys;
}
const vapidKeys = loadOrCreateVapidKeys();
webpush.setVapidDetails(
"https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
vapidKeys.publicKey,
vapidKeys.privateKey
);
function getPublicKey() {
return vapidKeys.publicKey;
}
/**
* Fire a native OS notification when this process is the Electron main process
* (i.e. the desktop app embeds the server in-process). Web Push is unreliable
* inside Electron Chromium-in-Electron ships without Firebase Cloud
* Messaging credentials, so `pushManager.subscribe()` in the renderer either
* fails or returns an endpoint that nothing can ever deliver to, leaving the
* `push_subscriptions` table empty. Calling Electron's main-process
* Notification API directly side-steps the push service entirely.
*
* Returns true when a notification was actually shown.
*
* @param {string} title
* @param {string} body
* @returns {boolean}
*/
function showNativeNotificationIfElectron(title, body) {
if (!process.versions || !process.versions.electron) return false;
try {
// `require("electron")` only resolves inside the Electron runtime; in a
// plain `node server/index.js` host it throws and we fall through.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Notification: ElectronNotification } = require("electron");
if (!ElectronNotification) return false;
if (
typeof ElectronNotification.isSupported === "function" &&
!ElectronNotification.isSupported()
) {
return false;
}
new ElectronNotification({ title, body, silent: false }).show();
return true;
} catch {
return false;
}
}
/**
* Dispatch a notification to every reachable surface:
* - A native Electron notification when hosted inside the desktop app.
* - A Web Push delivery to every subscribed browser endpoint.
*
* Both legs run unconditionally so whichever surface the user is on receives
* the alert. Under `npm start` the native leg is a no-op; under the desktop
* app the Web Push leg is typically a no-op (no FCM credentials in Electron,
* so `push_subscriptions` is empty).
*
* Returns `{ native, pushed, failed }` so the caller can surface what actually
* happened in its API response silent failures stop looking like success.
*/
async function sendPushToAll(db, title, body) {
const native = showNativeNotificationIfElectron(title, body);
const subscriptions = db.prepare("SELECT * FROM push_subscriptions").all();
if (subscriptions.length === 0) {
return { native, pushed: 0, failed: 0 };
}
const payload = JSON.stringify({
title,
body,
icon: "https://raw.githubusercontent.com/Smartgift-AI/Claude-Code-Monitor/main/client/public/favicon.ico",
badge:
"https://raw.githubusercontent.com/Smartgift-AI/Claude-Code-Monitor/main/client/public/favicon.ico",
silent: false,
sound: "default",
});
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload
)
)
);
// Remove subscriptions that are gone (HTTP 410); count what landed.
let pushed = 0;
let failed = 0;
for (let index = 0; index < results.length; index++) {
const result = results[index];
if (result.status === "fulfilled") {
pushed++;
} else {
failed++;
if (result.reason?.statusCode === 410) {
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(
subscriptions[index].endpoint
);
}
}
}
return { native, pushed, failed };
}
module.exports = { getPublicKey, sendPushToAll, showNativeNotificationIfElectron };
+53
View File
@@ -0,0 +1,53 @@
/**
* @file Self-hosted ReDoc API reference. ReDoc renders the OpenAPI spec as a
* clean, three-panel reference document a read-optimized complement to
* Swagger UI's interactive "try it out" console (both are served from the same
* `/api/openapi.json` spec). The ReDoc bundle ships with the `redoc`
* dependency and is served straight from `node_modules` rather than a CDN, so
* the docs render fully offline / air-gapped, consistent with the project's
* no-external-assets policy.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
/**
* Absolute path to the prebuilt ReDoc standalone bundle inside the installed
* `redoc` package. Resolved through Node's module resolution so it works
* regardless of hoisting / install layout. Throws if `redoc` is not installed.
* @returns {string}
*/
function redocBundlePath() {
return require.resolve("redoc/bundles/redoc.standalone.js");
}
/**
* Minimal HTML shell that boots ReDoc against a spec URL using the
* locally-served bundle. Makes no external network requests.
*
* @param {string} specUrl URL the browser fetches the OpenAPI JSON from.
* @param {string} bundleUrl URL the page loads the ReDoc bundle from.
* @param {string} title Document <title> and browser-tab label.
* @returns {string} A complete HTML document.
*/
function renderRedocHtml(specUrl, bundleUrl, title) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url="${specUrl}"></redoc>
<script src="${bundleUrl}"></script>
</body>
</html>
`;
}
module.exports = { redocBundlePath, renderRedocHtml };
Binary file not shown.
+567
View File
@@ -0,0 +1,567 @@
/**
* @file run-spawner.js
* @description Spawns and supervises Claude Code subprocesses for the
* dashboard's Run page. Two modes:
* - "headless" single-shot. Stdin is closed after spawn; the prompt
* lives in argv via `-p`. Process exits when the model
* finishes the turn.
* - "conversation" multi-turn. Stdin stays open; follow-up turns are
* delivered via JSON envelopes through stdin and the
* caller can pipe more messages until they kill or the
* child exits naturally.
*
* Conversation mode also supports resuming an existing session via
* `--resume <session-id>`, so the user can continue any prior Claude Code
* conversation from inside the dashboard.
*
* Output is always `--output-format stream-json --verbose` so the parser can
* deliver structured envelopes (system/init, assistant text+tool_use, user
* tool_result, result/success, etc). Each envelope is broadcast over the
* dashboard's existing WebSocket as a `run_stream` message; status changes
* (spawning running completed/error/killed) broadcast as `run_status`.
*
* Concurrency is capped (RUN_MAX_CONCURRENT, default 10) over the cap we
* throw ECONCURRENCY with the running set so the route can return 429.
*
* When a child truly finishes (real exit, or a spawn that never started) the
* handler registered via setRunExitHandler is called once. That inversion is
* how a lane gets released without this module requiring the lane router back.
*
* Each handle keeps a bounded in-memory envelope log (cap 500) so a client
* that attaches late can replay what it missed. Completed handles are reaped
* after 5 min but the underlying transcripts persist via the normal hook
* ingestion pipeline (every spawned `claude` fires hooks like any other
* session, so the run shows up in /sessions automatically).
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
// cross-spawn (not node:child_process): on Windows the npm-installed `claude`
// is a `.cmd` shim that plain spawn can't launch, and the naive fix (`shell:
// true`) would run argv — including the user-controlled prompt/model — through
// cmd.exe, opening a command-injection hole. cross-spawn resolves the shim and
// escapes arguments safely without a shell. On macOS/Linux it is a plain spawn.
const spawn = require("cross-spawn");
const { randomUUID } = require("node:crypto");
const { broadcast } = require("../websocket");
const { createLineParser } = require("./stream-json-parser");
// Persistence is best-effort and optional — load lazily so unit tests that
// don't bring up the full db can still exercise the spawner.
let dashboardRuns = null;
try {
dashboardRuns = require("./dashboard-runs");
} catch {
/* db-less environment, skip persistence */
}
function recordRun(handle) {
if (dashboardRuns) dashboardRuns.recordRun(handle);
}
function patchRun(args) {
if (dashboardRuns) dashboardRuns.patchRun(args);
}
// Whoever owns lanes registers here at boot (routes/lanes.js) so a finished run
// can release its lane. The dependency is inverted deliberately: the lane router
// already requires THIS module, and releasing needs the router's lanePayload /
// lastEventAge to broadcast — requiring it back would be a cycle.
let runExitHandler = null;
function setRunExitHandler(fn) {
runExitHandler = typeof fn === "function" ? fn : null;
}
/** Announce a truly-exited run. Never lets a listener break run bookkeeping. */
function notifyRunExit(handle) {
if (!runExitHandler) return;
try {
runExitHandler({ runId: handle.id, laneId: handle.laneId || null });
} catch {
/* a broken listener is not the run's problem */
}
}
// Effectively uncapped — claude's terminal TUI doesn't gate concurrent
// sessions, so we don't either. The number is high enough that a buggy
// client still can't fork-bomb the host before someone notices, but low
// enough that no human will ever hit it organically. Users who want a
// real cap can set RUN_MAX_CONCURRENT.
const MAX_CONCURRENT_DEFAULT = 10000;
const REAP_AFTER_MS = 5 * 60 * 1000; // keep handle for 5 min after exit
const STDOUT_TAIL_BYTES = 4 * 1024;
const STDERR_TAIL_BYTES = 4 * 1024;
// Cap stored envelopes per handle so a long-running conversation doesn't
// balloon memory. Late-attaching clients get this much history; the full
// transcript is always available via the existing /sessions/<id> view.
const MAX_ENVELOPES_PER_HANDLE = 500;
const handles = new Map();
const reapers = new Map();
function getMaxConcurrent() {
const raw = process.env.RUN_MAX_CONCURRENT;
if (!raw) return MAX_CONCURRENT_DEFAULT;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : MAX_CONCURRENT_DEFAULT;
}
function liveCount() {
let n = 0;
for (const h of handles.values()) {
if (h.status === "spawning" || h.status === "running") n++;
}
return n;
}
function tail(s, n) {
if (typeof s !== "string") return "";
if (s.length <= n) return s;
return s.slice(s.length - n);
}
/**
* Build argv for the `claude` invocation. The two modes have different argv
* shapes because of how Claude Code resolves the first user message:
*
* - HEADLESS: `-p "<prompt>"` carries the prompt; stdin is closed; Claude
* processes one turn and exits.
* - CONVERSATION: `--input-format stream-json` puts Claude in multi-turn
* mode where ALL user turns (including the first) come via stdin. When
* stream-json input is enabled, `-p` is silently ignored so we OMIT
* it and send the initial prompt over stdin in `spawnRun` immediately
* after the spawn handshake.
*/
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
function buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort }) {
const argv = [];
argv.push("--output-format", "stream-json");
argv.push("--verbose");
// Real character-by-character streaming. Without this flag Claude only
// emits the *final* assistant envelope, which makes the UI feel like the
// response arrives all at once. With it, we also receive `stream_event`
// envelopes (Anthropic Messages API streaming events) so the UI can
// render text + thinking deltas as they arrive.
argv.push("--include-partial-messages");
argv.push("--permission-mode", permissionMode || "acceptEdits");
if (mode === "headless") {
argv.push("-p", prompt);
} else {
argv.push("--input-format", "stream-json");
}
if (model) {
argv.push("--model", model);
}
if (effort && EFFORT_LEVELS.has(effort)) {
// Drives thinking depth: higher = more reasoning tokens before the
// assistant turn. Empty / unset means "inherit from the model's default".
argv.push("--effort", effort);
}
if (resumeSessionId) {
argv.push("--resume", resumeSessionId);
}
return argv;
}
/**
* Frame a stream-json user envelope. Used both for the initial conversation-
* mode prompt and for follow-up turns via sendInput.
*/
function userEnvelope(text, id) {
const e = {
type: "user",
message: { role: "user", content: text },
};
if (id) e.id = id;
return JSON.stringify(e) + "\n";
}
/**
* Strip dashboard-internal env vars from the child so the spawned `claude`
* doesn't accidentally pick up our hook-handler context (and to keep the
* child's auth entirely from the user's existing OAuth in $HOME).
*/
function cleanSpawnEnv() {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST;
return env;
}
function attachStreamHandlers(handle) {
const parser = createLineParser(
(envelope) => {
// First parsed envelope means the child is producing output → "running".
if (handle.status === "spawning") {
handle.status = "running";
broadcast("run_status", { id: handle.id, status: "running", at: Date.now() });
patchRun({ id: handle.id, status: "running" });
}
// Capture session_id off the system/init envelope — once we have it the
// dashboard can deep-link to /sessions/<id> on completion.
if (
envelope &&
envelope.type === "system" &&
envelope.subtype === "init" &&
typeof envelope.session_id === "string"
) {
const wasNull = !handle.sessionId;
handle.sessionId = envelope.session_id;
if (wasNull) patchRun({ id: handle.id, sessionId: envelope.session_id });
}
handle.envelopeCount += 1;
handle.envelopes.push(envelope);
// Keep only the most recent N — older entries are still in the disk
// transcript at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl,
// visible via the regular /sessions/<id> dashboard view.
if (handle.envelopes.length > MAX_ENVELOPES_PER_HANDLE) {
handle.envelopes.splice(0, handle.envelopes.length - MAX_ENVELOPES_PER_HANDLE);
}
broadcast("run_stream", { id: handle.id, envelope });
},
(err, raw) => {
handle.stderrBuffer += `[parse-error] ${err.message}: ${raw}\n`;
}
);
handle.child.stdout.on("data", (chunk) => {
const s = chunk.toString("utf8");
handle.stdoutBuffer = tail(handle.stdoutBuffer + s, STDOUT_TAIL_BYTES);
parser.push(s);
});
handle.child.stderr.on("data", (chunk) => {
handle.stderrBuffer = tail(handle.stderrBuffer + chunk.toString("utf8"), STDERR_TAIL_BYTES);
});
handle.child.on("error", (err) => {
// A spawn error has no corresponding `exit` event: the OS never started
// the child, so it can no longer touch the lane directory.
handle.actualExitedAt = Date.now();
handle.status = "error";
handle.error = err.message;
handle.endedAt = Date.now();
broadcast("run_status", {
id: handle.id,
status: "error",
error: err.message,
at: handle.endedAt,
});
patchRun({ id: handle.id, status: "error", endedAt: handle.endedAt });
scheduleReap(handle.id);
// A spawn that never started is just as finished as one that ran: without
// this the lane stays `running` forever with a dead run_id.
notifyRunExit(handle);
});
handle.child.on("exit", (code, signal) => {
parser.flush();
// `killRun` deliberately sets status to `killed` immediately after it
// requests SIGTERM. Keep this separate, exit-only signal so callers that
// must not touch a run's cwd until the OS reaps it can wait truthfully.
handle.actualExitedAt = Date.now();
if (handle.status === "killed") {
// already broadcast — patchRun already happened in stop()
} else {
handle.status = code === 0 ? "completed" : "error";
handle.exitCode = code;
handle.signal = signal;
handle.endedAt = Date.now();
broadcast("run_status", {
id: handle.id,
status: handle.status,
exitCode: code,
sessionId: handle.sessionId || null,
at: handle.endedAt,
});
patchRun({
id: handle.id,
status: handle.status,
exitCode: code,
sessionId: handle.sessionId || null,
endedAt: handle.endedAt,
});
}
scheduleReap(handle.id);
// Fires for a killed run too — killRun only flags `killed` before the OS
// reaps the child; a killed run is a finished run.
notifyRunExit(handle);
});
}
function scheduleReap(id) {
const existing = reapers.get(id);
if (existing) clearTimeout(existing);
const t = setTimeout(() => {
handles.delete(id);
reapers.delete(id);
}, REAP_AFTER_MS);
// Don't keep the process alive just for the reap timer.
if (typeof t.unref === "function") t.unref();
reapers.set(id, t);
}
/**
* @param {object} args
* @param {string} args.prompt
* @param {"headless"|"conversation"} args.mode
* @param {string} [args.cwd]
* @param {string} [args.model]
* @param {string} [args.permissionMode]
* @param {number} [args.laneId] Lane this run was started through; persisted so
* the Workspace page can list one lane's runs. Omitted by POST /api/run.
* @returns handle
*/
function spawnRun(args) {
const { prompt, mode, cwd, model, permissionMode, resumeSessionId, effort, laneId } = args || {};
if (typeof prompt !== "string") {
throw makeErr("EBADPROMPT", "prompt is required");
}
// Empty prompt is allowed only when resuming a conversation — claude
// idles on the resumed transcript until the user types a follow-up.
if (!prompt.trim() && !(mode === "conversation" && resumeSessionId)) {
throw makeErr("EBADPROMPT", "prompt is required");
}
if (mode !== "headless" && mode !== "conversation") {
throw makeErr("EBADMODE", `mode must be "headless" or "conversation"`);
}
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
}
if (resumeSessionId != null) {
if (typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId)) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
// Resume only makes sense in conversation mode (you want to keep talking).
// Headless `claude --resume` does run, but the UX of "send one prompt and
// exit" on a resumed session is confusing — disallow.
if (mode !== "conversation") {
throw makeErr("EBADMODE", "resumeSessionId requires conversation mode");
}
}
const max = getMaxConcurrent();
if (liveCount() >= max) {
const err = makeErr("ECONCURRENCY", `concurrency limit ${max} reached`);
err.running = Array.from(handles.values())
.filter((h) => h.status === "running" || h.status === "spawning")
.map((h) => ({ id: h.id, pid: h.pid, startedAt: h.startedAt, mode: h.mode }));
throw err;
}
const id = randomUUID();
const argv = buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort });
// cross-spawn handles the Windows `.cmd` shim safely (see the require above);
// deliberately no `shell` option, so argv is never parsed by cmd.exe.
const child = spawn("claude", argv, {
env: cleanSpawnEnv(),
cwd: cwd || process.cwd(),
stdio: ["pipe", "pipe", "pipe"],
});
const handle = {
id,
pid: child.pid || null,
mode,
cwd: cwd || process.cwd(),
model: model || null,
permissionMode: permissionMode || "acceptEdits",
effort: effort || null,
prompt,
argv,
resumeSessionId: resumeSessionId || null,
laneId: typeof laneId === "number" ? laneId : null,
status: "spawning",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
actualExitedAt: null,
sessionId: resumeSessionId || null, // optimistic; will be confirmed by system/init envelope
envelopeCount: 0,
envelopes: [],
stdoutBuffer: "",
stderrBuffer: "",
child,
};
handles.set(id, handle);
recordRun(handle);
attachStreamHandlers(handle);
if (mode === "headless") {
// Headless: prompt is in argv; close stdin so Claude knows nothing more
// is coming and exits after the one turn.
try {
child.stdin.end();
} catch {
/* ignore */
}
} else if (prompt && prompt.trim()) {
// Conversation: deliver the initial prompt over stdin so Claude in
// stream-json input mode actually starts processing it. Stdin stays
// open for follow-up turns.
try {
child.stdin.write(userEnvelope(prompt));
} catch (err) {
handle.stderrBuffer += `[stdin-write-error] ${err.message}\n`;
}
}
// Conversation with empty prompt (resume scenarios) — leave stdin open;
// claude will idle on the resumed conversation until the user types a
// follow-up via POST /:id/message.
broadcast("run_status", { id, status: "spawning", at: handle.startedAt });
return handle;
}
/**
* Send a follow-up user turn into a running conversation. Throws if the
* handle is not running, not in conversation mode, or stdin is closed.
*/
function sendInput(id, text) {
const handle = handles.get(id);
if (!handle) throw makeErr("ENOTFOUND", "run not found");
if (handle.mode !== "conversation") {
throw makeErr("EWRONGMODE", "only conversation mode accepts follow-up input");
}
if (handle.status !== "running" && handle.status !== "spawning") {
throw makeErr("ENOTRUNNING", `run is ${handle.status}`);
}
if (typeof text !== "string" || !text) {
throw makeErr("EBADINPUT", "text is required");
}
if (!handle.child || !handle.child.stdin || !handle.child.stdin.writable) {
throw makeErr("ESTDINCLOSED", "stdin is not writable");
}
const messageId = randomUUID();
handle.child.stdin.write(userEnvelope(text, messageId));
broadcast("run_input_ack", { id, messageId, at: Date.now() });
return { messageId };
}
function killRun(id) {
const handle = handles.get(id);
if (!handle) return false;
if (handle.status === "completed" || handle.status === "error" || handle.status === "killed") {
return true;
}
if (handle.child && !handle.child.killed) {
try {
handle.child.kill("SIGTERM");
} catch {
/* ignore */
}
setTimeout(() => {
const h = handles.get(id);
if (h && h.child && !h.actualExitedAt) {
try {
h.child.kill("SIGKILL");
} catch {
/* ignore */
}
}
}, 5000).unref?.();
}
handle.status = "killed";
handle.endedAt = Date.now();
broadcast("run_status", { id, status: "killed", at: handle.endedAt });
patchRun({ id, status: "killed", endedAt: handle.endedAt });
scheduleReap(id);
return true;
}
function publicHandle(handle, opts = {}) {
if (!handle) return null;
const out = {
id: handle.id,
pid: handle.pid,
mode: handle.mode,
cwd: handle.cwd,
model: handle.model,
permissionMode: handle.permissionMode,
effort: handle.effort || null,
prompt: handle.prompt,
argv: handle.argv,
resumeSessionId: handle.resumeSessionId || null,
status: handle.status,
startedAt: handle.startedAt,
endedAt: handle.endedAt,
exitCode: handle.exitCode,
signal: handle.signal,
error: handle.error,
actualExitedAt: handle.actualExitedAt,
sessionId: handle.sessionId,
envelopeCount: handle.envelopeCount,
stdoutTail: handle.stdoutBuffer,
stderrTail: handle.stderrBuffer,
};
if (opts.includeEnvelopes) {
out.envelopes = handle.envelopes.slice();
}
return out;
}
function getRun(id, opts = {}) {
return publicHandle(handles.get(id), opts);
}
function listRuns() {
return Array.from(handles.values())
.sort((a, b) => b.startedAt - a.startedAt)
.map(publicHandle);
}
function makeErr(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
// Test seam: inject a fake child (e.g. PassThrough streams) without invoking
// the real `claude` binary. Returns the handle.
function __injectChildForTest({ child, mode = "conversation", prompt = "test" }) {
const id = randomUUID();
const handle = {
id,
pid: 0,
mode,
cwd: process.cwd(),
model: null,
permissionMode: "acceptEdits",
effort: null,
prompt,
argv: ["-p", prompt],
resumeSessionId: null,
status: "spawning",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
actualExitedAt: null,
sessionId: null,
envelopeCount: 0,
envelopes: [],
stdoutBuffer: "",
stderrBuffer: "",
child,
};
handles.set(id, handle);
attachStreamHandlers(handle);
return handle;
}
function __reset() {
for (const t of reapers.values()) clearTimeout(t);
reapers.clear();
handles.clear();
}
module.exports = {
spawnRun,
setRunExitHandler,
sendInput,
killRun,
getRun,
listRuns,
liveCount,
getMaxConcurrent,
__injectChildForTest,
__reset,
};
+191
View File
@@ -0,0 +1,191 @@
/**
* @file scoped-stats.js
* @description Source-scoped variants of the dashboard's aggregate queries
* (stats + analytics). When the user restricts the "data scope" to a subset of
* machines (see server/lib/source-filter.js), the routes call these instead of
* the cached prepared statements in db.js so EVERY headline number session /
* agent / event counts, token totals, cost, daily charts, tool + type
* distributions reflects only the chosen sources.
*
* These build SQL dynamically (per request) and are used ONLY on the filtered
* path; the unfiltered default keeps using db.js's prepared statements, so the
* common zero-config case pays nothing for this feature.
*
* Every function takes a non-empty `sources` string array. The predicate is
* either `source IN (...)` (queries over `sessions`) or a `session_id IN
* (SELECT id FROM sessions WHERE source IN (...))` subquery (queries over
* events / agents / token_usage), always via bound parameters.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
/** `?,?,…` for N sources. */
function ph(sources) {
return sources.map(() => "?").join(",");
}
/** Subquery restricting a `session_id` column to the chosen sources. */
function sessionSubquery(sources) {
return `SELECT id FROM sessions WHERE source IN (${ph(sources)})`;
}
function statsOverview(db, sources) {
const sq = sessionSubquery(sources);
const p = ph(sources);
const row = db
.prepare(
`SELECT
(SELECT COUNT(*) FROM sessions WHERE source IN (${p})) as total_sessions,
(SELECT COUNT(*) FROM sessions WHERE status = 'active' AND source IN (${p})) as active_sessions,
(SELECT COUNT(*) FROM agents WHERE status IN ('working','waiting') AND session_id IN (${sq})) as active_agents,
(SELECT COUNT(*) FROM agents WHERE session_id IN (${sq})) as total_agents,
(SELECT COUNT(*) FROM events WHERE session_id IN (${sq})) as total_events`
)
.get(...sources, ...sources, ...sources, ...sources, ...sources);
return row;
}
function agentStatusCounts(db, sources) {
return db
.prepare(
`SELECT status, COUNT(*) as count FROM agents WHERE session_id IN (${sessionSubquery(
sources
)}) GROUP BY status`
)
.all(...sources);
}
function sessionStatusCounts(db, sources) {
return db
.prepare(
`SELECT status, COUNT(*) as count FROM sessions WHERE source IN (${ph(sources)}) GROUP BY status`
)
.all(...sources);
}
function countEventsToday(db, sources, toLocal, toUTC) {
return db
.prepare(
`SELECT COUNT(*) as count FROM events
WHERE created_at >= datetime('now', ?, 'start of day', ?)
AND session_id IN (${sessionSubquery(sources)})`
)
.get(toLocal, toUTC, ...sources);
}
function tokenTotals(db, sources) {
return db
.prepare(
`SELECT
COALESCE(SUM(input_tokens + baseline_input), 0) as total_input,
COALESCE(SUM(output_tokens + baseline_output), 0) as total_output,
COALESCE(SUM(cache_read_tokens + baseline_cache_read), 0) as total_cache_read,
COALESCE(SUM(cache_write_tokens + baseline_cache_write), 0) as total_cache_write,
COALESCE(SUM(cache_write_1h_tokens + baseline_cache_write_1h), 0) as total_cache_write_1h,
COALESCE(SUM(web_search_requests + baseline_web_search), 0) as total_web_search,
COALESCE(SUM(web_fetch_requests + baseline_web_fetch), 0) as total_web_fetch,
COALESCE(SUM(code_execution_requests + baseline_code_execution), 0) as total_code_execution
FROM token_usage WHERE session_id IN (${sessionSubquery(sources)})`
)
.get(...sources);
}
function toolUsageCounts(db, sources) {
return db
.prepare(
`SELECT tool_name, COUNT(*) as count FROM events
WHERE tool_name IS NOT NULL AND session_id IN (${sessionSubquery(sources)})
GROUP BY tool_name ORDER BY count DESC LIMIT 20`
)
.all(...sources);
}
function dailyEventCounts(db, sources, tzModifier) {
return db
.prepare(
`SELECT DATE(created_at, ?) as date, COUNT(*) as count FROM events
WHERE created_at >= DATE('now', '-365 days') AND session_id IN (${sessionSubquery(sources)})
GROUP BY 1 ORDER BY date ASC`
)
.all(tzModifier, ...sources);
}
function dailySessionCounts(db, sources, tzModifier) {
return db
.prepare(
`SELECT DATE(started_at, ?) as date, COUNT(*) as count FROM sessions
WHERE started_at >= DATE('now', '-365 days') AND source IN (${ph(sources)})
GROUP BY 1 ORDER BY date ASC`
)
.all(tzModifier, ...sources);
}
function agentTypeDistribution(db, sources) {
return db
.prepare(
`SELECT subagent_type, COUNT(*) as count FROM agents
WHERE type = 'subagent' AND subagent_type IS NOT NULL AND session_id IN (${sessionSubquery(
sources
)})
GROUP BY subagent_type ORDER BY count DESC`
)
.all(...sources);
}
function totalSubagentCount(db, sources) {
return db
.prepare(
`SELECT COUNT(*) as count FROM agents WHERE type = 'subagent' AND session_id IN (${sessionSubquery(
sources
)})`
)
.get(...sources);
}
function eventTypeCounts(db, sources) {
return db
.prepare(
`SELECT event_type, COUNT(*) as count FROM events
WHERE session_id IN (${sessionSubquery(sources)})
GROUP BY event_type ORDER BY count DESC`
)
.all(...sources);
}
function avgEventsPerSession(db, sources) {
const sq = sessionSubquery(sources);
return db
.prepare(
`SELECT ROUND(CAST(COUNT(*) AS REAL) /
MAX(1, (SELECT COUNT(*) FROM sessions WHERE source IN (${ph(sources)}))), 1) as avg
FROM events WHERE session_id IN (${sq})`
)
.get(...sources, ...sources);
}
/** token_usage rows joined to their session start date, scoped to sources. */
function scopedTokenUsageWithDate(db, sources) {
return db
.prepare(
`SELECT tu.*, DATE(s.started_at) as date
FROM token_usage tu JOIN sessions s ON s.id = tu.session_id
WHERE s.source IN (${ph(sources)})`
)
.all(...sources);
}
module.exports = {
statsOverview,
agentStatusCounts,
sessionStatusCounts,
countEventsToday,
tokenTotals,
toolUsageCounts,
dailyEventCounts,
dailySessionCounts,
agentTypeDistribution,
totalSubagentCount,
eventTypeCounts,
avgEventsPerSession,
scopedTokenUsageWithDate,
};
+177
View File
@@ -0,0 +1,177 @@
/**
* @file security.js
* @description Network-exposure hardening for the dashboard server
* (GHSA-gr74-4xfh-6jw9). The server historically bound 0.0.0.0 with no auth and
* `cors()` (Access-Control-Allow-Origin: *), exposing transcripts, data export,
* local-directory reads, ~/.claude writes, and a claude-spawning endpoint to any
* host on the network. This module centralizes the defenses:
*
* 1. Default bind to loopback (127.0.0.1); opt into a wider bind only via the
* explicit DASHBOARD_HOST env (with a startup warning).
* 2. Host-header allowlist rejects requests whose Host isn't loopback (or an
* operator-allowlisted name), which defeats DNS-rebinding drive-bys.
* 3. CORS restricted to loopback origins (no more `*`).
* 4. An OPTIONAL bearer token (DASHBOARD_TOKEN) gating /api/* and the
* WebSocket for operators who deliberately bind to a LAN. Off by default
* so the zero-config loopback experience is unchanged.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
// Hostnames that count as "this machine". "0.0.0.0" is included because a
// browser may resolve a 0.0.0.0 bind via localhost; an empty Host is treated as
// loopback (HTTP/1.0 / local tooling).
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", ""]);
/** The interface to bind. Loopback unless the operator opts into a wider bind. */
function resolveHost() {
const h = (process.env.DASHBOARD_HOST || "").trim();
return h || "127.0.0.1";
}
function isLoopbackHostname(name) {
return LOOPBACK_HOSTS.has(String(name || "").toLowerCase());
}
/** Extra Host-header names the operator allows (set when binding to a LAN). */
function allowedHostnames() {
return (process.env.DASHBOARD_ALLOWED_HOSTS || "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
/** Strip the port from a Host header, preserving bracketed IPv6 literals. */
function hostnameOf(hostHeader) {
const h = String(hostHeader || "");
if (h.startsWith("[")) {
const end = h.indexOf("]");
return end >= 0 ? h.slice(0, end + 1).toLowerCase() : h.toLowerCase();
}
return h.split(":")[0].toLowerCase();
}
function isHostAllowed(hostHeader) {
const name = hostnameOf(hostHeader);
return isLoopbackHostname(name) || allowedHostnames().includes(name);
}
/**
* Express middleware: reject requests whose Host header isn't loopback (or an
* operator-allowlisted name). This is the primary defense against DNS-rebinding
* a rebound attacker domain arrives with its own Host (e.g. evil.example) and
* is refused even though the TCP connection is locallocal.
*/
function hostGuard(req, res, next) {
if (isHostAllowed(req.headers.host)) return next();
return res.status(403).json({ error: { code: "EBADHOST", message: "host not allowed" } });
}
/**
* CORS options: allow same-origin / no-Origin (curl, the server's own client)
* and loopback origins; refuse everything else (so a cross-origin page cannot
* read responses). Credentials stay off the API is token- or trust-gated, not
* cookie-authed.
*/
function corsOptions() {
return {
origin(origin, cb) {
if (!origin) return cb(null, true);
try {
const u = new URL(origin);
if (
isLoopbackHostname(u.hostname) ||
allowedHostnames().includes(u.hostname.toLowerCase())
) {
return cb(null, true);
}
} catch {
/* malformed Origin → treat as disallowed */
}
return cb(null, false);
},
credentials: false,
};
}
/** The configured auth token, or null when auth is disabled (the default). */
function getDashboardToken() {
const t = process.env.DASHBOARD_TOKEN;
return typeof t === "string" && t.length > 0 ? t : null;
}
function tokensMatch(provided, expected) {
if (typeof provided !== "string" || provided.length === 0) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function extractToken(req) {
const auth = req.headers.authorization;
if (typeof auth === "string" && auth.startsWith("Bearer ")) return auth.slice(7);
const header = req.headers["x-dashboard-token"];
if (typeof header === "string" && header) return header;
if (req.query && typeof req.query.token === "string") return req.query.token;
return null;
}
// API subpaths exempt from the token gate even when a token is set:
// /health, /openapi.json, /docs — harmless metadata / docs.
// /hooks — local Claude Code hook ingestion (the hook handler posts to
// loopback and carries no token); loopback bind already protects it.
const TOKEN_EXEMPT_PREFIXES = ["/health", "/openapi.json", "/docs", "/hooks"];
/**
* Express middleware (mount at "/api"): when DASHBOARD_TOKEN is set, require a
* matching bearer token on every API route except the exempt prefixes. A no-op
* when no token is configured preserving the zero-config loopback default.
*/
function tokenGuard(req, res, next) {
const expected = getDashboardToken();
if (!expected) return next();
if (TOKEN_EXEMPT_PREFIXES.some((p) => req.path === p || req.path.startsWith(p + "/"))) {
return next();
}
if (tokensMatch(extractToken(req), expected)) return next();
return res
.status(401)
.json({ error: { code: "EUNAUTHORIZED", message: "missing or invalid dashboard token" } });
}
/**
* WebSocket upgrade auth. When a token is configured, the client must pass it as
* `?token=` (or an x-dashboard-token header). No-op when auth is disabled.
*/
function isWebSocketAuthorized(req) {
const expected = getDashboardToken();
if (!expected) return true;
try {
const u = new URL(req.url, "http://localhost");
if (tokensMatch(u.searchParams.get("token"), expected)) return true;
} catch {
/* fall through */
}
const header = req.headers["x-dashboard-token"];
if (typeof header === "string" && tokensMatch(header, expected)) return true;
return false;
}
module.exports = {
LOOPBACK_HOSTS,
resolveHost,
isLoopbackHostname,
allowedHostnames,
hostnameOf,
isHostAllowed,
hostGuard,
corsOptions,
getDashboardToken,
tokenGuard,
isWebSocketAuthorized,
// exported for tests
tokensMatch,
extractToken,
};
+295
View File
@@ -0,0 +1,295 @@
/**
* @file server-info.js
* @description Live discovery of every running dashboard server's TCP port.
*
* The conventional port is 4820, and a plain `npm start` setup almost always
* binds it. But more than one dashboard can run on a single machine most
* commonly the macOS desktop app side-by-side with `npm run dev`. The hook
* handler fans out to every live dashboard that uses a **different** SQLite
* data directory. Servers sharing the same `dataDir` receive hooks through a
* single lowest-port ingest target so events are never duplicated.
*
* The on-disk file is a JSON document under the Claude Code home directory.
* Every server writes its own entry on startup, prunes any stale entries it
* finds, and the hook handler reads the file and fans out one POST per live
* entry. Stale entries (process gone) are dropped on every read.
*
* Backwards compatibility: the file always carries the **legacy** single-
* record fields (`port`, `pid`, `startedAt`) at its root, set to the most
* recently started live server. Older hook handlers e.g. the one bundled
* inside a previously-installed `.app` that predates this multi-server
* format still parse the file successfully and reach at least one live
* server. The new shape lives under `servers: [...]`.
*
* Every function here is best-effort and never throws: discovery must never
* block server startup, and the hook handler must never fail because of it.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { getClaudeHome, getDataDir } = require("./claude-home");
/** Conventional dashboard port — used when discovery yields nothing. */
const DEFAULT_PORT = 4820;
/** Absolute path of the discovery file. */
function getServerInfoPath() {
return path.join(getClaudeHome(), ".agent-dashboard.json");
}
/**
* Canonical absolute path for comparing data directories across processes.
* Falls back to `path.resolve` when the directory does not exist yet.
*
* @param {string} dir
* @returns {string}
*/
function normalizeDataDir(dir) {
if (!dir || typeof dir !== "string") return "";
try {
return fs.realpathSync(dir);
} catch {
return path.resolve(dir);
}
}
/**
* Grouping key for hook-ingest deduplication. Entries without `dataDir` are
* treated as unique (legacy servers before this field existed).
*
* @param {{ port: number, dataDir?: string }} server
* @returns {string}
*/
function ingestGroupKey(server) {
if (server.dataDir) return normalizeDataDir(server.dataDir);
return `__legacy__:${server.port}`;
}
/**
* Read the discovery file and return its `servers` list, normalised. Handles
* both the new array shape and the legacy single-record shape so a file
* written by an older server is still understood.
*
* @returns {Array<{port: number, pid: number, startedAt: string}>}
*/
function readInfoFile() {
try {
const raw = fs.readFileSync(getServerInfoPath(), "utf8");
const parsed = JSON.parse(raw);
if (Array.isArray(parsed.servers)) {
return parsed.servers.filter((s) => s && Number.isInteger(s.port));
}
if (Number.isInteger(parsed.port)) {
// Legacy single-record file written by a server that predates this
// format. Treat the root object as the lone server entry.
return [{ port: parsed.port, pid: parsed.pid, startedAt: parsed.startedAt }];
}
return [];
} catch {
return [];
}
}
/**
* Whether a process is still running. `process.kill(pid, 0)` sends no signal;
* it only probes existence. EPERM means the process exists but is owned by
* another user still "alive" for our purposes.
*
* @param {number} pid
* @returns {boolean}
*/
function isPidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return Boolean(err) && err.code === "EPERM";
}
}
/** Most recently started entry — used to populate the legacy root fields. */
function mostRecent(servers) {
return servers.reduce((a, b) => {
const at = Date.parse(a.startedAt) || 0;
const bt = Date.parse(b.startedAt) || 0;
return bt > at ? b : a;
});
}
/**
* Write `{ servers, ...legacy }` to disk via temp file + atomic rename. The
* read-modify-write here is not file-system locked if two servers race to
* write at the exact same millisecond one entry may be momentarily lost; the
* loser's next write (or any read that triggers a prune) self-heals.
*/
function persist(servers) {
if (servers.length === 0) {
try {
fs.unlinkSync(getServerInfoPath());
} catch {
/* already gone */
}
return;
}
const recent = mostRecent(servers);
const payload = JSON.stringify(
{
// Legacy fields so an older hook handler (e.g. one bundled inside a
// previously-installed .app that predates the multi-server format)
// still resolves to a reachable port.
port: recent.port,
pid: recent.pid,
startedAt: recent.startedAt,
// The full list of live servers — the field new readers consume.
servers,
},
null,
2
);
const finalPath = getServerInfoPath();
const tmpPath = `${finalPath}.${process.pid}.tmp`;
fs.writeFileSync(tmpPath, payload);
fs.renameSync(tmpPath, finalPath);
}
/**
* Record the live server port so the hook handler (and any other local
* consumer) can find it. Other servers' entries are preserved; dead entries
* are pruned. Best-effort a failure here never interrupts server startup.
*
* @param {number} port - The port the HTTP server is listening on.
*/
function writeServerInfo(port) {
if (!Number.isInteger(port) || port <= 0) return;
try {
const dir = getClaudeHome();
fs.mkdirSync(dir, { recursive: true });
const existing = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && s.pid !== process.pid && isPidAlive(s.pid)
);
const ours = {
port,
pid: process.pid,
startedAt: new Date().toISOString(),
dataDir: normalizeDataDir(getDataDir()),
};
persist([...existing, ours]);
} catch {
// Discovery is an optimization, not a requirement — never block startup.
}
}
/** Remove this process's entry from the file. Safe to call when absent. */
function removeServerInfo() {
try {
const remaining = readInfoFile().filter((s) => s.pid !== process.pid);
persist(remaining);
} catch {
// Already gone, never written, or unreadable — nothing to do.
}
}
/**
* Resolve every live dashboard server's port. Result is ordered most-recent
* last (the order entries appear in the file).
*
* 1. `CLAUDE_DASHBOARD_PORT` explicit operator override; returned as the
* sole target so a test or one-off override doesn't fan out.
* 2. Live entries from the discovery file, pruned by PID liveness.
* 3. `[DEFAULT_PORT]` (`[4820]`) the conventional fallback when nothing
* else resolves.
*
* @returns {number[]}
*/
function resolveAllDashboardPorts() {
const envPort = parseInt(process.env.CLAUDE_DASHBOARD_PORT || "", 10);
if (Number.isInteger(envPort) && envPort > 0) return [envPort];
const live = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && isPidAlive(s.pid)
);
if (live.length > 0) {
// Dedupe by port in case the same port appears twice (defensive).
return [...new Set(live.map((s) => s.port))];
}
return [DEFAULT_PORT];
}
/**
* Ports that should receive hook POSTs. When several live servers share the
* same SQLite data directory, only the lowest port per directory is returned
* so parallel instances (Docker + dev, two terminals on the same DB) never
* double-ingest events.
*
* @returns {number[]}
*/
function resolveHookIngestPorts() {
const envPort = parseInt(process.env.CLAUDE_DASHBOARD_PORT || "", 10);
if (Number.isInteger(envPort) && envPort > 0) return [envPort];
const live = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && isPidAlive(s.pid)
);
if (live.length === 0) return [DEFAULT_PORT];
const byDataDir = new Map();
for (const server of live) {
const key = ingestGroupKey(server);
const prev = byDataDir.get(key);
if (!prev || server.port < prev.port) {
byDataDir.set(key, server);
}
}
return [...byDataDir.values()].map((s) => s.port).sort((a, b) => a - b);
}
/**
* Other live dashboard processes using the same SQLite data directory as this
* one. Used for startup warnings when multiple UIs point at one database.
*
* @returns {Array<{port: number, pid: number, startedAt: string}>}
*/
function peersSharingDataDir() {
try {
const mine = normalizeDataDir(getDataDir());
if (!mine) return [];
return readInfoFile().filter((s) => {
if (!Number.isInteger(s.port) || s.port <= 0) return false;
if (s.pid === process.pid) return false;
if (!isPidAlive(s.pid)) return false;
if (!s.dataDir) return false;
return normalizeDataDir(s.dataDir) === mine;
});
} catch {
return [];
}
}
/**
* Single-port helper kept for callers that have always asked the file for
* "the" port (e.g. legacy code paths and tests). Returns the first live
* server's port, or the default if none are alive.
*
* @returns {number}
*/
function resolveDashboardPort() {
return resolveAllDashboardPorts()[0] ?? DEFAULT_PORT;
}
module.exports = {
DEFAULT_PORT,
getServerInfoPath,
writeServerInfo,
removeServerInfo,
resolveDashboardPort,
resolveAllDashboardPorts,
resolveHookIngestPorts,
peersSharingDataDir,
// Exported for tests.
normalizeDataDir,
ingestGroupKey,
};
+116
View File
@@ -0,0 +1,116 @@
/**
* @file Process-liveness probe for Claude Code sessions. Answers "could any
* running `claude` CLI process own this session?" by listing live claude
* processes and their working directories. Used by the hooks watchdog to
* reap sessions whose SessionEnd hook was lost because the dashboard was not
* running when the user quit (e.g. Ctrl+C while the server was down) the
* only signal that a session ended is that hook, so a missed one previously
* left the session stuck in Waiting until the 3 h stale sweep.
*
* Fail-safe by design: whenever the probe cannot produce a trustworthy
* answer it reports `available: false` and the caller must change nothing.
* That covers Windows (no probe implementation), containers (host processes
* are invisible, so an empty process list would be a lie), missing `ps` /
* `lsof` binaries, and the DASHBOARD_LIVENESS_PROBE=0 escape hatch for
* setups where hooks arrive from another machine.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const { isInsideContainer } = require("../../scripts/install-hooks");
const UNAVAILABLE = () => ({ available: false, cwds: new Set() });
/**
* True when a `ps` args string is a Claude Code CLI process. Matches the
* bare binary (`claude`, `/usr/local/bin/claude`) and interpreter-launched
* shims (`node /path/to/claude`, `bun /path/to/claude`). The basename must
* be exactly "claude" so lookalikes (claude-mem, Claude.app's `Claude`
* binary, this project's own processes) never match.
*/
function isClaudeCommand(args) {
if (typeof args !== "string") return false;
const tokens = args.trim().split(/\s+/);
if (tokens.length === 0 || !tokens[0]) return false;
if (path.basename(tokens[0]) === "claude") return true;
const interpreter = path.basename(tokens[0]);
if ((interpreter === "node" || interpreter === "bun") && tokens[1]) {
return path.basename(tokens[1]) === "claude";
}
return false;
}
/** True when the probe is explicitly disabled via env. */
function probeDisabledByEnv() {
const raw = (process.env.DASHBOARD_LIVENESS_PROBE || "").trim().toLowerCase();
return raw === "0" || raw === "false" || raw === "no" || raw === "off";
}
/**
* Enumerate the working directories of every live `claude` CLI process.
*
* @returns {{ available: boolean, cwds: Set<string> }} `available: false`
* means "no trustworthy answer — do not act"; an `available: true` result
* with an empty set genuinely means no claude process is running.
*/
function probeLiveCwds() {
if (probeDisabledByEnv()) return UNAVAILABLE();
if (process.platform === "win32") return UNAVAILABLE();
if (isInsideContainer()) return UNAVAILABLE();
let psOut;
try {
psOut = execFileSync("ps", ["-Ao", "pid=,args="], {
encoding: "utf8",
timeout: 5_000,
maxBuffer: 16 * 1024 * 1024,
});
} catch {
return UNAVAILABLE();
}
const pids = [];
for (const line of psOut.split("\n")) {
const m = line.match(/^\s*(\d+)\s+(.*)$/);
if (m && isClaudeCommand(m[2])) pids.push(m[1]);
}
const cwds = new Set();
if (pids.length === 0) return { available: true, cwds };
if (process.platform === "linux") {
// /proc is authoritative and needs no external binary.
for (const pid of pids) {
try {
cwds.add(path.resolve(fs.readlinkSync(`/proc/${pid}/cwd`)));
} catch {
/* process exited between ps and readlink — skip */
}
}
return { available: true, cwds };
}
// macOS (and other BSD-likes): resolve each pid's cwd via lsof. `-Fn`
// machine format emits `p<pid>` / `f cwd` / `n<path>` records.
let lsofOut;
try {
lsofOut = execFileSync("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
encoding: "utf8",
timeout: 10_000,
maxBuffer: 16 * 1024 * 1024,
});
} catch (err) {
// lsof exits non-zero when SOME of the pids vanished between ps and
// lsof but still prints records for the rest — keep that partial
// output. No stdout at all (binary missing, hard failure) → no answer.
lsofOut = err && typeof err.stdout === "string" && err.stdout ? err.stdout : null;
if (lsofOut === null) return UNAVAILABLE();
}
for (const line of lsofOut.split("\n")) {
if (line.startsWith("n") && line.length > 1) cwds.add(path.resolve(line.slice(1)));
}
return { available: true, cwds };
}
module.exports = { probeLiveCwds, isClaudeCommand };
+66
View File
@@ -0,0 +1,66 @@
/**
* @file source-filter.js
* @description Shared helper for the "data scope" feature: restricting a query
* to sessions collected from a chosen set of machines (see server/db.js
* `sessions.source` and server/lib/remote-sync.js).
*
* The client passes `?sources=local,src_abc,...` on any data endpoint. Absent or
* empty means "all sources" (no filter) so every existing caller and the
* zero-config default are unaffected. This module turns that query param into a
* SQL fragment that is safe to append to any WHERE clause either directly on
* `sessions.source`, or, for tables that only carry a `session_id`, as a
* subquery so complex aggregate SQL (stats, analytics) needs only one extra AND.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
/**
* Parse the `sources` query param into a de-duplicated list, or null for
* "all sources" (no filtering).
* @param {import("express").Request} req
* @returns {string[]|null}
*/
function parseSources(req) {
const raw = req.query ? req.query.sources : undefined;
if (typeof raw !== "string") return null;
const list = [
...new Set(
raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
),
];
return list.length > 0 ? list : null;
}
/**
* Filter directly on a `source` column (used when `sessions` is in the query).
* @param {string[]|null} sources result of parseSources
* @param {string} [col] the qualified source column (default "s.source")
* @returns {{clause:string, params:string[]}} `clause` is "" when no filter
*/
function sourceColumnClause(sources, col = "s.source") {
if (!sources || sources.length === 0) return { clause: "", params: [] };
const placeholders = sources.map(() => "?").join(",");
return { clause: `${col} IN (${placeholders})`, params: sources };
}
/**
* Filter by session origin when only a `session_id` column is available, via a
* subquery against `sessions`. Lets stats/analytics/events/agents scope by
* source with a single extra AND and no FROM/GROUP BY changes.
* @param {string[]|null} sources result of parseSources
* @param {string} sessionIdCol the qualified session-id column (e.g. "e.session_id")
* @returns {{clause:string, params:string[]}} `clause` is "" when no filter
*/
function sessionIdInSourcesClause(sources, sessionIdCol) {
if (!sources || sources.length === 0) return { clause: "", params: [] };
const placeholders = sources.map(() => "?").join(",");
return {
clause: `${sessionIdCol} IN (SELECT id FROM sessions WHERE source IN (${placeholders}))`,
params: sources,
};
}
module.exports = { parseSources, sourceColumnClause, sessionIdInSourcesClause };
+146
View File
@@ -0,0 +1,146 @@
/**
* @file Matches hook events against pipeline-defined stage-detection rules.
* Inputs and templates are untrusted, so each exported function is total and
* compiled regular expressions are cached once for each pipeline object.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const compiledPipelines = new WeakMap();
// Only fields that identify WHAT a tool did, never editor payload like
// old_string/new_string, which can be whole code blocks.
const FLATTEN_KEYS = new Set([
"command",
"file_path",
"skill",
"prompt",
"pattern",
"description",
"subagent_type",
]);
const SIGNAL_MAX = 120;
/** Collapse whitespace and cap to a bounded, readable length. */
function capSignal(text) {
const collapsed = text.replace(/\s+/g, " ").trim();
return collapsed.length > SIGNAL_MAX ? `${collapsed.slice(0, SIGNAL_MAX)}` : collapsed;
}
/** Characters of surrounding context kept on each side of a matched span. */
const SIGNAL_CONTEXT = 24;
/**
* The span the rule actually matched, plus a little context the whole
* flattened input is usually a long shell line whose interesting part is a few
* words in the middle (`cd /very/long/path && npm run test:server 2>&1 | tail`).
* A rule with no regex matched on the tool name alone and has no span, so the
* caller keeps the flattened input.
*/
function matchedSpan(regex, input) {
if (!regex) return null;
// `regex` may carry /g from a user template; lastIndex would make exec()
// stateful across calls, so search from a known position every time.
regex.lastIndex = 0;
const m = regex.exec(input);
if (!m || typeof m.index !== "number") return null;
const start = Math.max(0, m.index - SIGNAL_CONTEXT);
const end = Math.min(input.length, m.index + m[0].length + SIGNAL_CONTEXT);
const prefix = start > 0 ? "…" : "";
const suffix = end < input.length ? "…" : "";
return `${prefix}${input.slice(start, end)}${suffix}`;
}
/** Return known identifying string fields from a tool input without recursively walking it. */
function flattenInput(toolInput) {
try {
if (typeof toolInput === "string") return toolInput;
if (toolInput === null || typeof toolInput !== "object") return "";
const strings = [];
if (Array.isArray(toolInput)) {
for (const value of toolInput) {
if (typeof value === "string") strings.push(value);
else if (value && typeof value === "object") {
for (const [key, nestedValue] of Object.entries(value)) {
if (FLATTEN_KEYS.has(key) && typeof nestedValue === "string") strings.push(nestedValue);
}
}
}
} else {
for (const [key, value] of Object.entries(toolInput)) {
if (FLATTEN_KEYS.has(key) && typeof value === "string") strings.push(value);
}
}
return strings.join(" ");
} catch {
return "";
}
}
/** Compile valid node rules once, omitting malformed nodes and regex patterns. */
function compileRules(pipeline) {
if (!pipeline || typeof pipeline !== "object") return [];
const cached = compiledPipelines.get(pipeline);
if (cached) return cached;
let nodes = [];
try {
nodes = Array.isArray(pipeline.nodes) ? pipeline.nodes : [];
} catch {
return [];
}
const compiled = nodes.map((node) => {
const rules = [];
try {
const detectRules = Array.isArray(node && node.detect) ? node.detect : [];
for (const rule of detectRules) {
if (!rule || typeof rule !== "object" || typeof rule.tool !== "string") continue;
if (rule.match === undefined) {
rules.push({ tool: rule.tool, regex: null });
continue;
}
if (typeof rule.match !== "string") continue;
try {
rules.push({ tool: rule.tool, regex: new RegExp(rule.match) });
} catch {
// A user-supplied invalid regex must never block hook ingestion.
}
}
} catch {
// Ignore a malformed node while preserving the remaining template.
}
return { nodeId: node && node.id, rules };
});
compiledPipelines.set(pipeline, compiled);
return compiled;
}
/** Infer the last matching pipeline stage from one hook event, or return null. */
function detect(pipeline, event) {
try {
if (!event || typeof event.tool_name !== "string" || !event.tool_name) return null;
const input = flattenInput(event.tool_input);
const fallback = input || event.tool_name;
let match = null;
for (const node of compileRules(pipeline)) {
if (typeof node.nodeId !== "string" || !node.nodeId) continue;
for (const rule of node.rules) {
if (rule.tool !== event.tool_name) continue;
if (!rule.regex || rule.regex.test(input)) {
const span = matchedSpan(rule.regex, input);
match = { nodeId: node.nodeId, signal: `\`${capSignal(span || fallback)}\`` };
break;
}
}
}
return match;
} catch {
return null;
}
}
module.exports = { flattenInput, compileRules, detect };
+40
View File
@@ -0,0 +1,40 @@
/**
* @file stream-json-parser.js
* @description Newline-delimited JSON line buffer for parsing `claude
* --output-format stream-json` output. Reassembles arbitrarily chunked stdout
* into discrete JSON envelopes (one per line). Robust to partial writes;
* malformed lines are reported via onError but never throw.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
function createLineParser(onObject, onError) {
let buf = "";
return {
push(chunk) {
buf += chunk;
let nlIdx;
while ((nlIdx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nlIdx).trim();
buf = buf.slice(nlIdx + 1);
if (!line) continue;
try {
onObject(JSON.parse(line));
} catch (err) {
if (typeof onError === "function") onError(err, line);
}
}
},
flush() {
const tail = buf.trim();
buf = "";
if (!tail) return;
try {
onObject(JSON.parse(tail));
} catch (err) {
if (typeof onError === "function") onError(err, tail);
}
},
};
}
module.exports = { createLineParser };
+130
View File
@@ -0,0 +1,130 @@
/**
* @file Shared helpers for normalizing Claude transcript `usage` records into
* per-bucket token tallies. Used by BOTH ingestion paths the live server-side
* parser (`server/lib/transcript-cache.js`) and the history importer
* (`scripts/import-history.js`) so the two stay in lockstep.
*
* A "bucket" is the unit cost is computed against: tokens are grouped by
* (model, speed, inference_geo, service_tier) because those four dimensions
* change the per-token RATE (fast mode, US data residency, Batch API). The
* dimensions are normalized to the small set of values that actually move
* price; anything unknown collapses to the standard/global default so old
* transcripts (which lack `speed` / `inference_geo` / `cache_creation`
* breakdown / `server_tool_use`) price exactly as they did before.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
// Separator for composite bucket keys — U+0001 (SOH) cannot occur in a model id.
const BUCKET_SEP = String.fromCharCode(1);
/** Pricing-relevant speed. Anything other than the fast research-preview tier is standard. */
function normalizeSpeed(usage) {
return usage && usage.speed === "fast" ? "fast" : "standard";
}
/**
* Pricing-relevant inference geography. Only US-pinned routing carries the 1.1x
* data-residency premium; "global", "not_available", and absent all map to the
* standard "global" rate.
*/
function normalizeGeo(usage) {
return usage && usage.inference_geo === "us" ? "us" : "global";
}
/** Pricing-relevant service tier. Only "batch" changes the rate (50% off). */
function normalizeTier(usage) {
return usage && usage.service_tier === "batch" ? "batch" : "standard";
}
/** Composite bucket key — stable string usable as an object property. */
function bucketKey(model, speed, geo, tier) {
return [model, speed, geo, tier].join(BUCKET_SEP);
}
/** A zeroed bucket carrying its four pricing dimensions. */
function emptyBucket(model, speed, geo, tier) {
return {
model,
speed,
geo,
tier,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0, // TOTAL ephemeral cache-creation tokens (5m + 1h)
cacheWrite1h: 0, // subset of cacheWrite that is the 1h tier; 5m = cacheWrite - cacheWrite1h
webSearch: 0, // server_tool_use.web_search_requests (billed per 1k)
webFetch: 0, // server_tool_use.web_fetch_requests (free; tracked for visibility)
codeExec: 0, // server_tool_use.code_execution_requests (time-billed; estimated)
};
}
/**
* Pull the numeric token / request fields out of a single `usage` record.
* Tolerant of the older shape: when `cache_creation` breakdown is absent the
* whole cache-write amount is treated as 5m (cacheWrite1h = 0), and a missing
* `server_tool_use` yields zero tool requests.
*/
function extractUsageFields(usage) {
if (!usage || typeof usage !== "object") {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cacheWrite1h: 0,
webSearch: 0,
webFetch: 0,
codeExec: 0,
};
}
const cc =
usage.cache_creation && typeof usage.cache_creation === "object" ? usage.cache_creation : null;
const ephem5m = cc ? cc.ephemeral_5m_input_tokens || 0 : 0;
const ephem1h = cc ? cc.ephemeral_1h_input_tokens || 0 : 0;
// Prefer the explicit total; fall back to the breakdown sum when only that is present.
const cacheWrite =
usage.cache_creation_input_tokens != null
? usage.cache_creation_input_tokens || 0
: ephem5m + ephem1h;
// Never let the 1h subset exceed the recorded total (guards malformed records).
const cacheWrite1h = Math.min(ephem1h, cacheWrite);
const stu =
usage.server_tool_use && typeof usage.server_tool_use === "object"
? usage.server_tool_use
: null;
return {
input: usage.input_tokens || 0,
output: usage.output_tokens || 0,
cacheRead: usage.cache_read_input_tokens || 0,
cacheWrite,
cacheWrite1h,
webSearch: stu ? stu.web_search_requests || 0 : 0,
webFetch: stu ? stu.web_fetch_requests || 0 : 0,
codeExec: stu ? stu.code_execution_requests || 0 : 0,
};
}
/** Add the numeric fields of `src` into `target` in place. */
function accumulateBucket(target, src) {
target.input += src.input || 0;
target.output += src.output || 0;
target.cacheRead += src.cacheRead || 0;
target.cacheWrite += src.cacheWrite || 0;
target.cacheWrite1h += src.cacheWrite1h || 0;
target.webSearch += src.webSearch || 0;
target.webFetch += src.webFetch || 0;
target.codeExec += src.codeExec || 0;
return target;
}
module.exports = {
BUCKET_SEP,
normalizeSpeed,
normalizeGeo,
normalizeTier,
bucketKey,
emptyBucket,
extractUsageFields,
accumulateBucket,
};
+793
View File
@@ -0,0 +1,793 @@
/**
* @file TranscriptCache class for efficient extraction of token usage and compaction data from JSONL transcript files, with stat-based caching and incremental reads to handle append-only growth without re-reading the entire file. Also extracts API error entries and turn duration system messages for enhanced analytics.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("fs");
const {
bucketKey,
emptyBucket,
extractUsageFields,
normalizeSpeed,
normalizeGeo,
normalizeTier,
accumulateBucket,
} = require("./token-usage");
const MAX_CACHE_ENTRIES = 200;
// Marker text Claude Code writes into the transcript when a turn is cancelled
// by the user (Esc). The synthetic entry is `type:"user"` and also carries an
// `interruptedMessageId` field; we accept either signal so detection survives
// minor format drift. No hook fires on interrupt, so this is the only on-disk
// evidence the watchdog can use to un-stick a session left in "working".
const INTERRUPT_RE = /\[Request interrupted by user/i;
// True when the transcript's tail is a user-interrupt that was never followed
// by real turn activity (a new prompt or model output). Both timestamps come
// from Claude Code's clock, so the comparison is immune to the server/transcript
// skew that breaks a sub-second pre-output Esc. `>=` so an interrupt that ties
// the last activity (interrupt written in the same instant) still counts.
function computePendingInterrupt(lastInterruptTs, lastTurnTs) {
if (!lastInterruptTs) return false;
if (!lastTurnTs) return true;
return lastInterruptTs >= lastTurnTs;
}
function hasInterruptText(message) {
if (!message || typeof message !== "object") return false;
const c = message.content;
if (typeof c === "string") return INTERRUPT_RE.test(c);
if (Array.isArray(c)) {
for (const block of c) {
if (block && typeof block.text === "string" && INTERRUPT_RE.test(block.text)) return true;
}
}
return false;
}
// Hard cap on the length of each per-entry growable array (turnDurations,
// errors, compaction.entries, usageExtras.{service_tiers,speeds,inference_geos}).
// Past this point we keep the *tail* — the most recent N items — so the
// cache reflects current state. Older items are NOT lost from the system:
// they are already persisted to the events table by routes/hooks.js, with
// dedup logic that prevents re-insertion when the cache re-reads them.
// Configurable via TRANSCRIPT_CACHE_MAX_ARRAY_LEN env var.
const MAX_ARRAY_LEN = (() => {
const raw = parseInt(process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN, 10);
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
})();
// Watermark for in-flight trimming during _consumeLine. We trim back to
// MAX_ARRAY_LEN whenever an array reaches 2*MAX_ARRAY_LEN, so a full-file
// parse cannot accumulate an unbounded transient before _finalizeState runs.
// Amortized O(N): each item is touched by a splice at most ~once.
const PARSE_TRIM_WATERMARK = MAX_ARRAY_LEN * 2;
// Cap on the captured first-user-message text. 500 chars matches the task
// truncation the hook ingestor already applies to subagent prompts, so the
// descriptor can be reused verbatim as an agent task downstream.
const FIRST_USER_MESSAGE_MAX_LEN = 500;
// Synthetic user entries whose text is CLI plumbing, not something the human
// typed: local slash-command invocations/output and the caveat preamble
// Claude Code writes before locally-generated messages. These must never
// become a session descriptor.
const SYNTHETIC_USER_TEXT_RE =
/^<(?:command-name|command-message|local-command-stdout|local-command-caveat)>/;
/**
* Extract the human-typed text of a user transcript entry, or null when the
* entry is not a real prompt: tool-result entries, meta/caveat lines, local
* slash-command plumbing, compact summaries, and user-interrupt markers are
* all skipped. Shared with scripts/import-history.js so imported and live
* sessions derive the identical descriptor.
*/
function extractFirstUserText(entry) {
if (entry.isMeta || entry.isCompactSummary) return null;
if (entry.interruptedMessageId != null || hasInterruptText(entry.message)) return null;
const msg = entry.message;
if (!msg || typeof msg !== "object" || msg.role !== "user") return null;
const content = msg.content;
let text = null;
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
// Tool-result entries are `role:"user"` too — skip any entry carrying a
// tool_result block rather than mining text out of a mixed payload.
if (content.some((b) => b && b.type === "tool_result")) return null;
text = content
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text)
.join(" ");
}
if (typeof text !== "string") return null;
// Collapse newlines/runs of whitespace so the descriptor reads as one line.
text = text.replace(/\s+/g, " ").trim();
if (!text || SYNTHETIC_USER_TEXT_RE.test(text)) return null;
return text.length > FIRST_USER_MESSAGE_MAX_LEN
? text.slice(0, FIRST_USER_MESSAGE_MAX_LEN)
: text;
}
class TranscriptCache {
constructor(maxEntries = MAX_CACHE_ENTRIES) {
this._cache = new Map();
this._maxEntries = maxEntries;
this._hits = 0;
this._misses = 0;
}
/**
* Extract token usage and compaction data from a JSONL transcript file.
* Uses stat-based caching with incremental reads for append-only growth.
* Returns null if file doesn't exist or has no data.
*/
extract(transcriptPath) {
if (!transcriptPath) return null;
try {
let stat;
try {
stat = fs.statSync(transcriptPath);
} catch {
return null;
}
const key = transcriptPath;
const cached = this._cache.get(key);
// Cache hit: file unchanged (same mtime + size)
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
this._hits++;
return cached.result;
}
this._misses++;
// File shrunk or first read → full re-read
if (!cached || stat.size < cached.bytesRead) {
const result = this._fullRead(transcriptPath);
this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
return result;
}
// File grew → incremental read from last position
if (stat.size > cached.bytesRead) {
const incremental = this._streamRange(transcriptPath, cached.bytesRead, stat.size);
if (incremental) {
const merged = this._merge(cached, incremental);
const hasTokens = Object.keys(merged.tokensByModel).length > 0;
const hasTurnDurations = merged.turnDurations && merged.turnDurations.length > 0;
const hasUsageExtras =
merged.usageExtras &&
(merged.usageExtras.service_tiers.length > 0 ||
merged.usageExtras.speeds.length > 0 ||
merged.usageExtras.inference_geos.length > 0);
const result = {
tokensByModel: hasTokens ? merged.tokensByModel : null,
compaction: merged.compaction,
errors: merged.errors,
turnDurations: hasTurnDurations ? merged.turnDurations : null,
thinkingBlockCount: merged.thinkingBlockCount || 0,
usageExtras: hasUsageExtras ? merged.usageExtras : null,
latestModel: merged.latestModel || null,
customTitle: merged.customTitle || null,
aiTitle: merged.aiTitle || null,
firstUserMessage: merged.firstUserMessage || null,
lastInterruptTs: merged.lastInterruptTs || null,
lastTurnTs: merged.lastTurnTs || null,
pendingInterrupt: computePendingInterrupt(merged.lastInterruptTs, merged.lastTurnTs),
};
if (
!result.tokensByModel &&
!result.compaction &&
!result.errors &&
!result.turnDurations &&
!result.thinkingBlockCount &&
!result.usageExtras &&
!result.latestModel &&
!result.customTitle &&
!result.aiTitle &&
!result.firstUserMessage &&
!result.lastInterruptTs &&
!result.lastTurnTs
) {
this._set(key, {
mtimeMs: stat.mtimeMs,
size: stat.size,
bytesRead: stat.size,
result: null,
});
return null;
}
this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
return result;
}
// Only whitespace/newlines appended
this._set(key, {
...cached,
mtimeMs: stat.mtimeMs,
size: stat.size,
bytesRead: stat.size,
});
return cached.result;
}
// Same size, different mtime — content may have been rewritten (compaction)
const result = this._fullRead(transcriptPath);
this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
return result;
} catch {
return null;
}
}
/**
* Extract only compaction entries from a JSONL file.
* Replacement for findCompactionsInFile uses the same cache, no duplicate reads.
*/
extractCompactions(transcriptPath) {
const result = this.extract(transcriptPath);
if (!result || !result.compaction) return [];
return result.compaction.entries.map((e) => ({ ...e }));
}
/**
* Full re-read using chunked streaming. Avoids materializing the whole file
* as a single JS string, so files larger than V8's max string length
* (~512 MiB on 64-bit Node) parse without aborting the process with
* "FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal".
*/
_fullRead(filePath) {
let size;
try {
size = fs.statSync(filePath).size;
} catch {
return null;
}
return this._streamRange(filePath, 0, size);
}
/**
* Sync chunked range reader + line parser.
* Reads [startOffset, endOffset) in fixed-size chunks, splits on 0x0A bytes,
* decodes each complete line as UTF-8 (safe: 0x0A never appears inside a
* UTF-8 multibyte sequence), and feeds it to _consumeLine. Partial trailing
* bytes between chunks are held in a byte buffer so multibyte characters
* straddling a chunk boundary are not corrupted. Never builds a string
* larger than a single line, so V8 string-length limits cannot be hit.
*/
_streamRange(filePath, startOffset, endOffset) {
const state = this._initParseState();
if (endOffset <= startOffset) return this._finalizeState(state);
const CHUNK = 4 * 1024 * 1024; // 4 MiB
const MAX_PENDING = 64 * 1024 * 1024; // hard cap on a single line
const buf = Buffer.allocUnsafe(CHUNK);
let pending = null; // bytes of partial trailing line not yet terminated by \n
let pendingLen = 0;
let pos = startOffset;
let fd;
try {
try {
fd = fs.openSync(filePath, "r");
} catch {
return this._finalizeState(state);
}
while (pos < endOffset) {
const want = Math.min(CHUNK, endOffset - pos);
let got;
try {
got = fs.readSync(fd, buf, 0, want, pos);
} catch {
break;
}
if (got <= 0) break;
pos += got;
let lineStart = 0;
for (let i = 0; i < got; i++) {
if (buf[i] !== 0x0a) continue;
let line;
if (pendingLen) {
const need = pendingLen + (i - lineStart);
const lineBuf = Buffer.allocUnsafe(need);
pending.copy(lineBuf, 0, 0, pendingLen);
buf.copy(lineBuf, pendingLen, lineStart, i);
line = lineBuf.toString("utf8");
pending = null;
pendingLen = 0;
} else {
line = buf.toString("utf8", lineStart, i);
}
if (line.length && line.charCodeAt(line.length - 1) === 13) {
line = line.slice(0, -1); // strip CR
}
if (line) this._consumeLine(line, state);
lineStart = i + 1;
}
if (lineStart < got) {
const tailLen = got - lineStart;
const newLen = pendingLen + tailLen;
if (newLen > MAX_PENDING) {
// Pathological single line — drop accumulated bytes and skip
// forward to the next newline rather than OOM. Loss is bounded
// to one malformed line.
pending = null;
pendingLen = 0;
} else {
if (!pending) {
pending = Buffer.allocUnsafe(Math.max(newLen, 8192));
} else if (pending.length < newLen) {
const grow = Buffer.allocUnsafe(Math.max(newLen, pending.length * 2));
pending.copy(grow, 0, 0, pendingLen);
pending = grow;
}
buf.copy(pending, pendingLen, lineStart, got);
pendingLen = newLen;
}
}
}
if (pendingLen) {
let line = pending.toString("utf8", 0, pendingLen);
if (line.length && line.charCodeAt(line.length - 1) === 13) {
line = line.slice(0, -1);
}
if (line) this._consumeLine(line, state);
}
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
return this._finalizeState(state);
}
_initParseState() {
return {
tokensByModel: {},
compaction: null,
errors: [],
turnDurations: [],
thinkingBlockCount: 0,
usageExtras: {
service_tiers: new Set(),
speeds: new Set(),
inference_geos: new Set(),
},
// Track the model of the most recent assistant entry. JSONL is
// append-only and parsed in file order, so the last value seen here is
// the user's *current* model — used downstream to keep session.model in
// sync when the user invokes /model mid-session.
latestModel: null,
// Track the latest human-readable session title. Two sources, both
// append-only metadata lines: `custom-title` (explicit /rename, claude
// -n, picker Ctrl+R) and `ai-title` (auto-generated / plan-accept).
// Last value wins. Used downstream to keep session.name in sync in real
// time — custom titles take precedence over ai titles.
customTitle: null,
aiTitle: null,
// First real user prompt of the session (tool-result / meta / command
// entries skipped), whitespace-collapsed and length-capped. Used
// downstream as a fallback descriptor for placeholder-named sessions
// and their main agent — first value wins (it describes what the
// session set out to do), unlike the last-wins titles above.
firstUserMessage: null,
// Timestamps (ISO 8601, all from Claude Code's clock) used to recover a
// turn cancelled with no hook. `lastInterruptTs` is the most recent
// user-interrupt (Esc) entry; `lastTurnTs` is the most recent real turn
// activity (assistant output or a genuine user prompt). Comparing the
// two — both same-clock — tells us whether the transcript TAIL is an
// unrecovered interrupt. This holds even when Esc is pressed before any
// output (a sub-second interrupt), which a server-vs-transcript clock
// comparison cannot, since the UserPromptSubmit event is stamped later.
lastInterruptTs: null,
lastTurnTs: null,
};
}
_consumeLine(line, state) {
if (!line) return;
let entry;
try {
entry = JSON.parse(line);
} catch {
return;
}
// Session title metadata lines — sparse, no usage payload. Capture the
// latest value of each kind (append-only → last wins) and bail early.
if (entry.type === "custom-title") {
if (typeof entry.customTitle === "string" && entry.customTitle.trim()) {
state.customTitle = entry.customTitle;
}
return;
}
if (entry.type === "ai-title") {
if (typeof entry.aiTitle === "string" && entry.aiTitle.trim()) {
state.aiTitle = entry.aiTitle;
}
return;
}
// User-interrupt (Esc) marker. No hook fires for cancellation, so capture
// the timestamp here for the watchdog to move a stuck session back to
// waiting-for-input. The entry carries no usage/model, so return early.
if (
entry.type === "user" &&
(entry.interruptedMessageId != null || hasInterruptText(entry.message))
) {
if (entry.timestamp) state.lastInterruptTs = entry.timestamp;
return;
}
// Real turn activity — assistant output or a genuine (non-interrupt) user
// prompt. Tracking its latest timestamp lets _finalizeState decide whether
// a later interrupt was superseded by the user resuming (new prompt /
// model output) or is still the unrecovered tail of the transcript.
if ((entry.type === "assistant" || entry.type === "user") && entry.timestamp) {
if (!state.lastTurnTs || entry.timestamp > state.lastTurnTs)
state.lastTurnTs = entry.timestamp;
}
// First real user prompt — captured once (first wins; the file is parsed
// in order). extractFirstUserText filters out tool-result, meta, and
// slash-command plumbing entries so only human-typed text qualifies.
if (state.firstUserMessage === null && entry.type === "user") {
const firstText = extractFirstUserText(entry);
if (firstText) state.firstUserMessage = firstText;
}
if (entry.isCompactSummary) {
if (!state.compaction) state.compaction = { count: 0, entries: [] };
state.compaction.count++;
state.compaction.entries.push({
uuid: entry.uuid || null,
timestamp: entry.timestamp || null,
});
if (state.compaction.entries.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.compaction.entries);
}
}
if (entry.type === "system" && entry.subtype === "turn_duration" && entry.durationMs) {
const turnTs = entry.timestamp
? typeof entry.timestamp === "number"
? new Date(entry.timestamp).toISOString()
: entry.timestamp
: null;
state.turnDurations.push({ durationMs: entry.durationMs, timestamp: turnTs });
if (state.turnDurations.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.turnDurations);
}
}
const msg = entry.message || entry;
if (msg.type === "error" && msg.error) {
state.errors.push({
type: msg.error.type || "unknown_error",
message: msg.error.message || "Unknown API error",
timestamp: entry.timestamp || null,
});
if (state.errors.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.errors);
}
return;
}
if (entry.isApiErrorMessage) {
const errContent = Array.isArray(entry.message?.content) ? entry.message.content : [];
const errText = errContent[0]?.text ? errContent[0].text.slice(0, 500) : "Unknown error";
state.errors.push({
type: entry.error || "unknown_error",
message: errText,
timestamp: entry.timestamp || null,
});
if (state.errors.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.errors);
}
return;
}
const model = msg.model;
if (!model || model === "<synthetic>" || !msg.usage) return;
state.latestModel = model;
// Bucket tokens by the pricing dimensions (speed / geo / tier) so cost can
// apply fast-mode, data-residency, and Batch modifiers per bucket. The value
// carries those dimensions so the DB writer can key the row correctly.
const speed = normalizeSpeed(msg.usage);
const geo = normalizeGeo(msg.usage);
const tier = normalizeTier(msg.usage);
const key = bucketKey(model, speed, geo, tier);
if (!state.tokensByModel[key]) {
state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
}
accumulateBucket(state.tokensByModel[key], extractUsageFields(msg.usage));
if (msg.usage.service_tier) state.usageExtras.service_tiers.add(msg.usage.service_tier);
if (msg.usage.speed) state.usageExtras.speeds.add(msg.usage.speed);
if (msg.usage.inference_geo && msg.usage.inference_geo !== "not_available") {
state.usageExtras.inference_geos.add(msg.usage.inference_geo);
}
const msgContent = msg.content || [];
if (Array.isArray(msgContent)) {
for (const block of msgContent) {
if (block.type === "thinking") state.thinkingBlockCount++;
}
}
}
_finalizeState(state) {
const hasTokens = Object.keys(state.tokensByModel).length > 0;
const hasErrors = state.errors.length > 0;
const hasTurnDurations = state.turnDurations.length > 0;
const hasUsageExtras =
state.usageExtras.service_tiers.size > 0 ||
state.usageExtras.speeds.size > 0 ||
state.usageExtras.inference_geos.size > 0;
if (
!hasTokens &&
!state.compaction &&
!hasErrors &&
!hasTurnDurations &&
!state.thinkingBlockCount &&
!hasUsageExtras &&
!state.latestModel &&
!state.customTitle &&
!state.aiTitle &&
!state.firstUserMessage &&
!state.lastInterruptTs &&
!state.lastTurnTs
) {
return null;
}
this._trimArray(state.errors);
this._trimArray(state.turnDurations);
if (state.compaction) this._trimArray(state.compaction.entries);
// usageExtras are accumulated as Sets and serialized as arrays here, with
// the same MAX_ARRAY_LEN tail cap applied via _capArrayFromSet.
const serializedExtras = hasUsageExtras
? {
service_tiers: this._capArrayFromSet(state.usageExtras.service_tiers),
speeds: this._capArrayFromSet(state.usageExtras.speeds),
inference_geos: this._capArrayFromSet(state.usageExtras.inference_geos),
}
: null;
return {
tokensByModel: hasTokens ? state.tokensByModel : null,
compaction: state.compaction,
errors: hasErrors ? state.errors : null,
turnDurations: hasTurnDurations ? state.turnDurations : null,
thinkingBlockCount: state.thinkingBlockCount,
usageExtras: serializedExtras,
latestModel: state.latestModel,
customTitle: state.customTitle,
aiTitle: state.aiTitle,
firstUserMessage: state.firstUserMessage,
lastInterruptTs: state.lastInterruptTs,
lastTurnTs: state.lastTurnTs,
pendingInterrupt: computePendingInterrupt(state.lastInterruptTs, state.lastTurnTs),
};
}
/**
* Parse an in-memory JSONL string. Retained for callers that already have
* the content as a string. Internal extraction paths now use _streamRange
* directly to avoid the V8 string-length limit on multi-hundred-MiB files.
*/
_parseContent(content) {
const state = this._initParseState();
let start = 0;
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) !== 10) continue;
let line = content.slice(start, i);
if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
if (line) this._consumeLine(line, state);
start = i + 1;
}
if (start < content.length) {
let line = content.slice(start);
if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
if (line) this._consumeLine(line, state);
}
return this._finalizeState(state);
}
_merge(cached, incremental) {
const tokensByModel = cached.result?.tokensByModel
? this._cloneTokens(cached.result.tokensByModel)
: {};
if (incremental && incremental.tokensByModel) {
for (const [key, tokens] of Object.entries(incremental.tokensByModel)) {
if (!tokensByModel[key]) {
tokensByModel[key] = emptyBucket(tokens.model, tokens.speed, tokens.geo, tokens.tier);
}
accumulateBucket(tokensByModel[key], tokens);
}
}
let compaction = cached.result?.compaction
? this._cloneCompaction(cached.result.compaction)
: null;
if (incremental && incremental.compaction) {
if (!compaction) compaction = { count: 0, entries: [] };
compaction.count += incremental.compaction.count;
compaction.entries.push(...incremental.compaction.entries);
this._trimArray(compaction.entries);
}
let errors = cached.result?.errors ? [...cached.result.errors] : null;
if (incremental && incremental.errors) {
if (!errors) errors = [];
errors.push(...incremental.errors);
this._trimArray(errors);
}
let turnDurations = cached.result?.turnDurations ? [...cached.result.turnDurations] : null;
if (incremental && incremental.turnDurations) {
if (!turnDurations) turnDurations = [];
turnDurations.push(...incremental.turnDurations);
this._trimArray(turnDurations);
}
const thinkingBlockCount =
(cached.result?.thinkingBlockCount || 0) + (incremental?.thinkingBlockCount || 0);
let usageExtras = cached.result?.usageExtras
? this._cloneUsageExtras(cached.result.usageExtras)
: null;
if (incremental && incremental.usageExtras) {
if (!usageExtras) {
usageExtras = { service_tiers: [], speeds: [], inference_geos: [] };
}
// Merge and deduplicate
const merged = {
service_tiers: new Set([
...usageExtras.service_tiers,
...incremental.usageExtras.service_tiers,
]),
speeds: new Set([...usageExtras.speeds, ...incremental.usageExtras.speeds]),
inference_geos: new Set([
...usageExtras.inference_geos,
...incremental.usageExtras.inference_geos,
]),
};
usageExtras = {
service_tiers: this._capArrayFromSet(merged.service_tiers),
speeds: this._capArrayFromSet(merged.speeds),
inference_geos: this._capArrayFromSet(merged.inference_geos),
};
}
// JSONL is append-only and parsed in order, so the incremental block's
// latestModel (when present) is the newest reading — fall back to the
// previously-cached value when the new chunk had no assistant entries.
const latestModel =
(incremental && incremental.latestModel) || cached.result?.latestModel || null;
// Same append-only logic for the session titles: the newest title line in
// the incremental chunk wins, else keep what was cached.
const customTitle =
(incremental && incremental.customTitle) || cached.result?.customTitle || null;
const aiTitle = (incremental && incremental.aiTitle) || cached.result?.aiTitle || null;
// First user message is first-wins (the opposite of the titles): the
// cached value was parsed from earlier in the file, so it stays; the
// incremental chunk only fills it when nothing was captured before.
const firstUserMessage =
cached.result?.firstUserMessage || (incremental && incremental.firstUserMessage) || null;
// Append-only: a newer interrupt / turn-activity timestamp in the
// incremental chunk supersedes the cached one, otherwise keep what was
// already known. pendingInterrupt is derived from the two by the caller.
const lastInterruptTs =
(incremental && incremental.lastInterruptTs) || cached.result?.lastInterruptTs || null;
const lastTurnTs = (incremental && incremental.lastTurnTs) || cached.result?.lastTurnTs || null;
return {
tokensByModel,
compaction,
errors,
turnDurations,
thinkingBlockCount,
usageExtras,
latestModel,
customTitle,
aiTitle,
firstUserMessage,
lastInterruptTs,
lastTurnTs,
};
}
_cloneTokens(tokensByModel) {
if (!tokensByModel) return null;
const clone = {};
for (const [model, t] of Object.entries(tokensByModel)) {
clone[model] = { ...t };
}
return clone;
}
_cloneCompaction(compaction) {
if (!compaction) return null;
return { count: compaction.count, entries: compaction.entries.map((e) => ({ ...e })) };
}
_cloneUsageExtras(extras) {
if (!extras) return null;
return {
service_tiers: [...(extras.service_tiers || [])],
speeds: [...(extras.speeds || [])],
inference_geos: [...(extras.inference_geos || [])],
};
}
/** Set cache entry with LRU eviction when at capacity */
_set(key, entry) {
// Delete first so re-insertion moves key to end of Map iteration order
this._cache.delete(key);
this._cache.set(key, entry);
// Evict oldest entries (first in Map iteration order) if over limit
while (this._cache.size > this._maxEntries) {
const oldest = this._cache.keys().next().value;
this._cache.delete(oldest);
}
}
/** Trim an array in-place to keep only the last `maxLen` items. No-op on falsy. */
_trimArray(arr, maxLen = MAX_ARRAY_LEN) {
if (!arr || !Array.isArray(arr) || arr.length <= maxLen) return;
arr.splice(0, arr.length - maxLen);
}
/** Convert Set to array with the same MAX_ARRAY_LEN tail cap. */
_capArrayFromSet(set) {
const arr = [...set];
this._trimArray(arr);
return arr;
}
/** Number of entries currently cached */
get size() {
return this._cache.size;
}
/** Remove a specific path from cache */
invalidate(transcriptPath) {
this._cache.delete(transcriptPath);
}
/** Clear all cached entries */
clear() {
this._cache.clear();
}
/** Return cache stats for diagnostics */
stats() {
const total = this._hits + this._misses;
return {
size: this._cache.size,
maxSize: this._maxEntries,
hits: this._hits,
misses: this._misses,
hitRate: total > 0 ? +((this._hits / total) * 100).toFixed(1) : 0,
keys: [...this._cache.keys()],
};
}
}
module.exports = TranscriptCache;
module.exports.extractFirstUserText = extractFirstUserText;
+256
View File
@@ -0,0 +1,256 @@
/**
* @file Detects whether the dashboard git checkout is behind the canonical
* remote default branch (origin/master or origin/main on a
* direct clone) after a non-destructive fetch. Branch- and fork-aware:
* picks the right remote, recognises feature-branch checkouts, and shapes
* manual_command so it actually closes the gap for the user's situation.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { execFile } = require("child_process");
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
// This build tracks its OWN repository only: origin points at
// git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor. "upstream" is
// deliberately absent from the priority list — a stray upstream remote must
// never make the update checker report commits from somebody else's repo.
const REMOTE_PRIORITY = ["origin"];
function execGit(cwd, args, opts = {}) {
const timeout = opts.timeout ?? 120_000;
return new Promise((resolve, reject) => {
execFile(
"git",
args,
{ cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8" },
(err, stdout) => {
if (err) reject(err);
else resolve(String(stdout).trim());
}
);
});
}
async function listRemotes(gitRoot) {
try {
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
return out
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
} catch {
return [];
}
}
async function pickCanonicalRemote(gitRoot) {
const remotes = await listRemotes(gitRoot);
for (const candidate of REMOTE_PRIORITY) {
if (remotes.includes(candidate)) return candidate;
}
return remotes[0] || null;
}
async function resolveCompareRefForRemote(gitRoot, remote) {
const tryRefs = [`${remote}/master`, `${remote}/main`];
for (const ref of tryRefs) {
try {
await execGit(gitRoot, ["rev-parse", "--verify", ref], { timeout: 10_000 });
return ref;
} catch {
// continue
}
}
try {
const sym = await execGit(gitRoot, ["symbolic-ref", `refs/remotes/${remote}/HEAD`], {
timeout: 10_000,
});
const m = sym.match(/^refs\/remotes\/(.+)$/);
if (m) return m[1];
} catch {
// ignore
}
return null;
}
async function getCurrentBranch(gitRoot) {
try {
const branch = await execGit(gitRoot, ["symbolic-ref", "--short", "HEAD"], {
timeout: 10_000,
});
return branch || null;
} catch {
return null; // detached HEAD
}
}
async function getBranchUpstream(gitRoot) {
try {
return await execGit(gitRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
timeout: 10_000,
});
} catch {
return null; // no tracking branch configured
}
}
function stripRemotePrefix(ref) {
// "upstream/master" -> "master"; "origin/feature/foo" -> "feature/foo"
const idx = ref.indexOf("/");
return idx === -1 ? ref : ref.slice(idx + 1);
}
/**
* @param {string} [gitRoot]
* @param {{ skipFetch?: boolean }} [options]
* @returns {Promise<object>}
*/
async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
const root = path.resolve(gitRoot);
const gitDir = path.join(root, ".git");
if (!fs.existsSync(gitDir)) {
return {
git_repo: false,
update_available: false,
repo_root: root,
manual_command: null,
message: "Install directory is not a git clone; check for updates manually.",
};
}
const canonicalRemote = await pickCanonicalRemote(root);
if (!canonicalRemote) {
return {
git_repo: true,
update_available: false,
repo_root: root,
remote_ref: null,
local_sha: null,
remote_sha: null,
commits_behind: 0,
message: "No git remotes configured; automatic update check skipped.",
};
}
if (!options.skipFetch) {
try {
await execGit(root, ["fetch", canonicalRemote, "--prune"], { timeout: 120_000 });
} catch (err) {
return {
git_repo: true,
update_available: false,
repo_root: root,
canonical_remote: canonicalRemote,
fetch_error: err.message || String(err),
message: `Could not reach ${canonicalRemote}; try again when online.`,
};
}
}
const remoteRef = await resolveCompareRefForRemote(root, canonicalRemote);
if (!remoteRef) {
return {
git_repo: true,
update_available: false,
repo_root: root,
canonical_remote: canonicalRemote,
message: `Could not resolve ${canonicalRemote}/master, ${canonicalRemote}/main, or ${canonicalRemote}/HEAD.`,
};
}
const currentBranch = await getCurrentBranch(root);
const branchUpstream = await getBranchUpstream(root);
const tracksCanonical = branchUpstream === remoteRef;
let localSha;
let remoteSha;
let commitsBehind = 0;
try {
localSha = await execGit(root, ["rev-parse", "HEAD"], { timeout: 10_000 });
remoteSha = await execGit(root, ["rev-parse", remoteRef], { timeout: 10_000 });
const countStr = await execGit(root, ["rev-list", "--count", `HEAD..${remoteRef}`], {
timeout: 30_000,
});
commitsBehind = Number.parseInt(countStr, 10);
if (Number.isNaN(commitsBehind)) commitsBehind = 0;
} catch (err) {
return {
git_repo: true,
update_available: false,
repo_root: root,
canonical_remote: canonicalRemote,
remote_ref: remoteRef,
message: err.message || String(err),
};
}
const updateAvailable = commitsBehind > 0;
const isProd = process.env.NODE_ENV === "production";
const installSteps = ["npm run setup"];
if (isProd) installSteps.push("npm run build");
// Branch-aware manual_command. Three situations:
// 1. tracksCanonical: HEAD's tracked upstream IS the canonical ref. A
// plain `git pull --ff-only` does the right thing — typical clone on
// the default branch.
// 2. Same branch *name* as canonical but different upstream (the fork
// case: local master tracking origin/master, canonical is
// upstream/master). Need to fetch the canonical remote and merge it
// into the local branch.
// 3. Anything else (feature branch, detached HEAD): pulling the current
// branch wouldn't bring in canonical commits, so don't suggest it.
// Offer a fetch and let the user decide how to integrate.
const canonicalBranchName = stripRemotePrefix(remoteRef);
let manualParts;
let situationNote;
let situation;
if (tracksCanonical) {
situation = "tracking_canonical";
manualParts = [`cd "${root}"`, "git pull --ff-only", ...installSteps];
situationNote = null;
} else if (currentBranch && currentBranch === canonicalBranchName) {
situation = "fork_or_diverged_tracking";
manualParts = [
`cd "${root}"`,
`git fetch ${canonicalRemote}`,
`git merge --ff-only ${remoteRef}`,
...installSteps,
];
situationNote = `You're on '${currentBranch}' tracking '${
branchUpstream || "no upstream"
}'. This command fast-forwards your branch from ${remoteRef} (the canonical default).`;
} else {
situation = currentBranch ? "feature_branch" : "detached_head";
manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote}`];
situationNote = currentBranch
? `You're on '${currentBranch}', not the canonical default branch (${remoteRef}). Fetched commits won't be pulled into your branch — rebase or merge ${remoteRef} when you're ready.`
: `HEAD is detached. Fetched commits stay under ${remoteRef}; check out the canonical default branch when ready.`;
}
const manualCommand = manualParts.join(" && ");
return {
git_repo: true,
update_available: updateAvailable,
repo_root: root,
remote_ref: remoteRef,
canonical_remote: canonicalRemote,
current_branch: currentBranch,
tracking_upstream: branchUpstream,
tracks_canonical: tracksCanonical,
situation,
local_sha: localSha,
remote_sha: remoteSha,
commits_behind: commitsBehind,
manual_command: manualCommand,
situation_note: situationNote,
message: updateAvailable
? `${commitsBehind} commit(s) on ${remoteRef} not in your checkout.`
: "Your checkout includes the tip of the canonical default branch.",
};
}
module.exports = { getUpdatesStatus, DEFAULT_ROOT };
+501
View File
@@ -0,0 +1,501 @@
/**
* @file Webhook provider registry. Each provider is described declaratively
* its display label, "family" (which determines optional HMAC/custom-header
* support), the credential fields it needs, how its outbound URL is resolved,
* any auth headers, and a payload formatter that turns a fired alert into that
* provider's native request body. server/lib/webhooks.js consumes this registry
* to build and deliver requests; routes/webhooks.js uses it for validation and
* for the redacted provider metadata exposed to the UI.
*
* Adding a provider = one entry here (+ a formatter). No delivery/route changes.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
// ── Shared helpers ──────────────────────────────────────────────────────────
function truncate(value, max) {
const s = String(value == null ? "" : value);
return s.length > max ? `${s.slice(0, max - 1)}` : s;
}
function escHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function parseDetails(alert) {
if (alert.details == null) return null;
if (typeof alert.details === "object") return alert.details;
try {
return JSON.parse(alert.details);
} catch {
return alert.details;
}
}
// [{ title, value, short }] for the chat platforms that use Slack-style
// attachment fields (Slack legacy, Mattermost, Rocket.Chat).
function attachmentFields(alert) {
const fields = [{ title: "Type", value: alert.rule_type, short: true }];
if (alert.session_id)
fields.push({ title: "Session", value: truncate(alert.session_id, 120), short: true });
if (alert.agent_id)
fields.push({ title: "Agent", value: truncate(alert.agent_id, 120), short: true });
return fields;
}
const ACCENT_HEX = "#EF4444";
const ACCENT_INT = 0xef4444;
// ── Formatters ──────────────────────────────────────────────────────────────
// Slack incoming webhook — Block Kit. `text` is the required fallback string.
function formatSlack(alert) {
const ctx = [`Type: \`${alert.rule_type}\``];
if (alert.session_id) ctx.push(`Session: \`${truncate(alert.session_id, 64)}\``);
if (alert.agent_id) ctx.push(`Agent: \`${truncate(alert.agent_id, 64)}\``);
ctx.push(alert.triggered_at);
return {
text: truncate(`🔔 ${alert.rule_name}: ${alert.message}`, 3000),
blocks: [
{
type: "header",
text: { type: "plain_text", text: truncate(`🔔 ${alert.rule_name}`, 150), emoji: true },
},
{ type: "section", text: { type: "mrkdwn", text: truncate(alert.message, 2900) } },
{ type: "context", elements: [{ type: "mrkdwn", text: truncate(ctx.join(" • "), 1900) }] },
],
};
}
// Discord webhook — a single rich embed.
function formatDiscord(alert) {
const fields = [{ name: "Type", value: truncate(alert.rule_type, 1024), inline: true }];
if (alert.session_id)
fields.push({ name: "Session", value: truncate(alert.session_id, 1024), inline: true });
if (alert.agent_id)
fields.push({ name: "Agent", value: truncate(alert.agent_id, 1024), inline: true });
return {
username: "Claude Code Monitor",
embeds: [
{
title: truncate(`🔔 ${alert.rule_name}`, 256),
description: truncate(alert.message, 4000),
color: ACCENT_INT,
fields,
footer: { text: "Claude Code Agent Monitor" },
timestamp: alert.triggered_at,
},
],
};
}
// Microsoft Teams — Adaptive Card delivered via a Power Automate "Workflows"
// webhook. The legacy O365 Connector + MessageCard transport was retired
// (connectors progressively disabled May 1822 2026), so the target URL is a
// Workflows "When a Teams webhook request is received" URL and the body is the
// {type:"message", attachments:[adaptive card]} envelope that flow expects.
function formatTeams(alert) {
const facts = [{ title: "Type", value: alert.rule_type }];
if (alert.session_id) facts.push({ title: "Session", value: truncate(alert.session_id, 256) });
if (alert.agent_id) facts.push({ title: "Agent", value: truncate(alert.agent_id, 256) });
facts.push({ title: "Triggered", value: alert.triggered_at });
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
contentUrl: null,
content: {
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
size: "Large",
weight: "Bolder",
color: "Attention",
text: truncate(`🔔 ${alert.rule_name}`, 500),
wrap: true,
},
{ type: "TextBlock", text: truncate(alert.message, 4000), wrap: true },
{ type: "FactSet", facts },
],
},
},
],
};
}
// Google Chat incoming webhook — simple text message with basic markdown
// (*bold*, `code`). Reliable across spaces without card-schema pitfalls.
function formatGoogleChat(alert) {
const lines = [`🔔 *${alert.rule_name}*`, alert.message, ""];
const meta = [`\`${alert.rule_type}\``];
if (alert.session_id) meta.push(`session \`${truncate(alert.session_id, 64)}\``);
if (alert.agent_id) meta.push(`agent \`${truncate(alert.agent_id, 64)}\``);
lines.push(meta.join(" · "));
return { text: truncate(lines.join("\n"), 4000) };
}
// Mattermost incoming webhook — Slack-compatible (legacy attachments).
function formatMattermost(alert) {
return {
username: "Claude Code Monitor",
text: `🔔 **${alert.rule_name}**`,
attachments: [
{
fallback: truncate(`${alert.rule_name}: ${alert.message}`, 1000),
color: ACCENT_HEX,
text: truncate(alert.message, 3000),
fields: attachmentFields(alert),
footer: "Claude Code Agent Monitor",
},
],
};
}
// Rocket.Chat incoming webhook — text + Slack-style attachments.
function formatRocketChat(alert) {
return {
alias: "Claude Code Monitor",
text: `🔔 *${alert.rule_name}*`,
attachments: [
{
title: truncate(alert.rule_name, 256),
text: truncate(alert.message, 3000),
color: ACCENT_HEX,
fields: attachmentFields(alert),
},
],
};
}
// Telegram Bot API sendMessage. chat_id comes from config; the bot token is in
// the resolved URL. HTML parse mode, so message text is HTML-escaped.
function formatTelegram(alert, config) {
const lines = [`🔔 <b>${escHtml(alert.rule_name)}</b>`, escHtml(alert.message)];
const meta = [`<code>${escHtml(alert.rule_type)}</code>`];
if (alert.session_id)
meta.push(`session <code>${escHtml(truncate(alert.session_id, 64))}</code>`);
lines.push("", meta.join(" · "));
return {
chat_id: config.chat_id,
parse_mode: "HTML",
disable_web_page_preview: true,
text: truncate(lines.join("\n"), 4096),
};
}
// PagerDuty Events API v2 (trigger). routing_key + severity from config.
// dedup_key groups repeat firings of the same rule+session into one incident.
function formatPagerDuty(alert, config) {
return {
routing_key: config.routing_key,
event_action: "trigger",
dedup_key: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
payload: {
summary: truncate(`${alert.rule_name}: ${alert.message}`, 1024),
source: alert.session_id || "claude-code-agent-monitor",
severity: config.severity || "warning",
custom_details: {
rule_name: alert.rule_name,
rule_type: alert.rule_type,
session_id: alert.session_id || null,
agent_id: alert.agent_id || null,
message: alert.message,
details: parseDetails(alert),
triggered_at: alert.triggered_at,
},
},
};
}
// Opsgenie Alert API. api_key is sent as the GenieKey auth header (see
// authFrom), not in the body. alias dedups; region selects the host.
function formatOpsgenie(alert) {
return {
message: truncate(`${alert.rule_name}: ${alert.message}`, 130),
alias: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
description: truncate(alert.message, 15000),
source: "claude-code-agent-monitor",
tags: ["claude-code", alert.rule_type].filter(Boolean),
details: {
rule_name: String(alert.rule_name),
rule_type: String(alert.rule_type),
session_id: alert.session_id ? String(alert.session_id) : "",
agent_id: alert.agent_id ? String(alert.agent_id) : "",
triggered_at: String(alert.triggered_at),
},
};
}
// Splunk On-Call (VictorOps) generic REST endpoint. The API + routing key live
// in the user-pasted URL; severity maps to message_type.
function formatSplunkOnCall(alert, config) {
return {
message_type: config.severity || "WARNING",
entity_id: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
entity_display_name: truncate(alert.rule_name, 256),
state_message: truncate(
`${alert.message}\n\ntype: ${alert.rule_type}${alert.session_id ? `\nsession: ${alert.session_id}` : ""}`,
20000
),
monitoring_tool: "claude-code-agent-monitor",
};
}
// Generic / automation platforms (Zapier, Make, n8n, Pipedream) — a clean,
// stable JSON envelope. Optional HMAC signing + custom headers handled by the
// caller (server/lib/webhooks.js) for the whole generic family.
function formatGeneric(alert) {
return {
event: "alert.triggered",
source: "claude-code-agent-monitor",
sent_at: new Date().toISOString(),
alert: {
id: alert.id ?? null,
rule_id: alert.rule_id ?? null,
rule_name: alert.rule_name,
rule_type: alert.rule_type,
session_id: alert.session_id ?? null,
agent_id: alert.agent_id ?? null,
message: alert.message,
details: parseDetails(alert),
triggered_at: alert.triggered_at,
},
};
}
// ── Registry ────────────────────────────────────────────────────────────────
//
// family:
// "chat" — incoming-webhook chat platforms (no extra auth, https URL)
// "api" — alert/event APIs with credentials and/or derived URLs
// "generic" — arbitrary-JSON endpoints; support optional HMAC + custom headers
//
// needsUrl — the user must supply the outbound URL
// https — enforce https on a user-supplied URL (false allows http for local)
// defaultUrl — fallback URL when the user supplies none
// urlFrom(cfg)— derive the URL from config (user supplies no URL)
// authFrom(cfg)— derive auth request headers from config
// fields — provider config fields (rendered by the UI, validated server-side)
const PROVIDERS = {
slack: { label: "Slack", family: "chat", needsUrl: true, https: true, format: formatSlack },
discord: { label: "Discord", family: "chat", needsUrl: true, https: true, format: formatDiscord },
teams: {
label: "Microsoft Teams",
family: "chat",
needsUrl: true,
https: true,
urlHint:
"Power Automate Workflows URL (Teams → Workflows → 'Post to a channel when a webhook request is received')",
format: formatTeams,
},
google_chat: {
label: "Google Chat",
family: "chat",
needsUrl: true,
https: true,
format: formatGoogleChat,
},
mattermost: {
label: "Mattermost",
family: "chat",
needsUrl: true,
https: true,
format: formatMattermost,
},
rocketchat: {
label: "Rocket.Chat",
family: "chat",
needsUrl: true,
https: true,
format: formatRocketChat,
},
telegram: {
label: "Telegram",
family: "api",
https: true,
fields: [
{ key: "bot_token", label: "Bot token", secret: true, required: true },
{ key: "chat_id", label: "Chat ID", required: true },
],
urlFrom: (c) => (c.bot_token ? `https://api.telegram.org/bot${c.bot_token}/sendMessage` : null),
format: formatTelegram,
},
pagerduty: {
label: "PagerDuty",
family: "api",
https: true,
defaultUrl: "https://events.pagerduty.com/v2/enqueue",
fields: [
{ key: "routing_key", label: "Integration (routing) key", secret: true, required: true },
{
key: "severity",
label: "Severity",
type: "enum",
options: ["info", "warning", "error", "critical"],
default: "warning",
},
],
format: formatPagerDuty,
},
opsgenie: {
label: "Opsgenie",
family: "api",
https: true,
fields: [
{ key: "api_key", label: "API key", secret: true, required: true },
{ key: "region", label: "Region", type: "enum", options: ["us", "eu"], default: "us" },
],
urlFrom: (c) =>
c.region === "eu"
? "https://api.eu.opsgenie.com/v2/alerts"
: "https://api.opsgenie.com/v2/alerts",
authFrom: (c) => (c.api_key ? { Authorization: `GenieKey ${c.api_key}` } : {}),
format: formatOpsgenie,
},
splunk_oncall: {
label: "Splunk On-Call",
family: "api",
needsUrl: true,
https: true,
urlHint: "VictorOps REST endpoint URL (contains your API + routing key)",
fields: [
{
key: "severity",
label: "Message type",
type: "enum",
options: ["CRITICAL", "WARNING", "INFO"],
default: "WARNING",
},
],
format: formatSplunkOnCall,
// VictorOps returns HTTP 200 even when it rejects the event — the real
// outcome is in the body ({ result: "success" | "failure" }). Inspect it so
// a logical failure isn't silently recorded as delivered.
verifyResponse: (text) => {
if (!text) return { ok: true };
try {
const j = JSON.parse(text);
if (j && typeof j.result === "string" && j.result.toLowerCase() === "failure") {
return { ok: false, error: j.message || "Splunk On-Call reported failure" };
}
} catch {
/* non-JSON 200 body — trust the status */
}
return { ok: true };
},
},
zapier: {
label: "Zapier",
family: "generic",
needsUrl: true,
https: true,
format: formatGeneric,
},
make: { label: "Make", family: "generic", needsUrl: true, https: true, format: formatGeneric },
n8n: { label: "n8n", family: "generic", needsUrl: true, https: false, format: formatGeneric },
pipedream: {
label: "Pipedream",
family: "generic",
needsUrl: true,
https: true,
format: formatGeneric,
},
generic: {
label: "Generic (custom JSON)",
family: "generic",
needsUrl: true,
https: false,
format: formatGeneric,
},
};
const WEBHOOK_TYPES = Object.keys(PROVIDERS);
function isGenericFamily(type) {
return PROVIDERS[type]?.family === "generic";
}
/** Resolve the outbound URL for a target: derived → user-supplied → default. */
function resolveUrl(target) {
const p = PROVIDERS[target.type];
if (!p) return target.url || null;
if (p.urlFrom) {
const derived = p.urlFrom(target.config || {});
if (derived) return derived;
}
if (target.url) return target.url;
return p.defaultUrl || null;
}
/** Provider-derived auth headers (e.g. Opsgenie GenieKey). */
function resolveAuthHeaders(target) {
const p = PROVIDERS[target.type];
if (p?.authFrom) return p.authFrom(target.config || {}) || {};
return {};
}
function formatPayload(type, alert, config = {}) {
const p = PROVIDERS[type] || PROVIDERS.generic;
return p.format(alert, config);
}
/** Whether a user-supplied URL is required for this provider type. */
function urlRequired(type) {
const p = PROVIDERS[type];
if (!p) return true;
if (p.urlFrom || p.defaultUrl) return false;
return !!p.needsUrl;
}
/** Redacted, serializable provider metadata for the UI/API. */
function publicProviders() {
return WEBHOOK_TYPES.map((type) => {
const p = PROVIDERS[type];
return {
type,
label: p.label,
family: p.family,
url_required: urlRequired(type),
has_default_url: !!p.defaultUrl,
derives_url: !!p.urlFrom,
allow_http: p.https === false,
url_hint: p.urlHint || null,
supports_secret: p.family === "generic",
supports_headers: p.family === "generic",
fields: (p.fields || []).map((f) => ({
key: f.key,
label: f.label,
secret: !!f.secret,
required: !!f.required,
type: f.type || "string",
options: f.options || null,
default: f.default ?? null,
})),
};
});
}
module.exports = {
PROVIDERS,
WEBHOOK_TYPES,
isGenericFamily,
resolveUrl,
resolveAuthHeaders,
formatPayload,
urlRequired,
publicProviders,
truncate,
};
+309
View File
@@ -0,0 +1,309 @@
/**
* @file Universal webhook delivery for fired alerts. A "target" is an outbound
* destination described by the provider registry (server/lib/webhook-providers.js)
* Slack, Discord, Teams, Mattermost, Rocket.Chat, Telegram, PagerDuty,
* Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream, or a generic endpoint.
* When the alerting engine fires an alert (server/lib/alerts.js), it calls
* dispatchAlert(), which formats the provider-native payload and POSTs it to
* every enabled target (optionally scoped to specific rules) with a timeout and
* bounded retry/backoff. Every attempt-chain is recorded in webhook_deliveries.
*
* Delivery is detached and fully fail-safe: it never throws into, slows, or
* blocks the alert path or hook ingestion.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const crypto = require("crypto");
const { stmts } = require("../db");
const {
PROVIDERS,
WEBHOOK_TYPES,
isGenericFamily,
resolveUrl,
resolveAuthHeaders,
formatPayload,
truncate,
} = require("./webhook-providers");
// Tunables (env-overridable so tests can shrink timeouts/backoff). All read at
// module load — restart to change.
function posEnv(name, fallback) {
const raw = parseInt(process.env[name], 10);
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
}
const TIMEOUT_MS = posEnv("WEBHOOK_TIMEOUT_MS", 10_000);
const MAX_ATTEMPTS = posEnv("WEBHOOK_MAX_ATTEMPTS", 3);
const RETRY_BASE_MS = posEnv("WEBHOOK_RETRY_BASE_MS", 1500);
// Enabled-target cache. Alert fires are hot; targets only change through the
// CRUD routes, which call invalidateWebhookCache().
let targetsCache = null;
function invalidateWebhookCache() {
targetsCache = null;
}
/** Parse the JSON columns and coerce the enabled flag for a raw target row. */
function normalizeTarget(row) {
if (!row) return null;
let headers = null;
let ruleIds = null;
let config = null;
try {
headers = row.headers ? JSON.parse(row.headers) : null;
} catch {
/* tolerate hand-edited bad JSON — extra headers simply not applied */
}
try {
ruleIds = row.rule_ids ? JSON.parse(row.rule_ids) : null;
} catch {
/* tolerate bad JSON — target falls back to "all rules" */
}
try {
config = row.config ? JSON.parse(row.config) : null;
} catch {
/* tolerate bad JSON — provider config falls back to empty */
}
return { ...row, enabled: row.enabled === 1, headers, rule_ids: ruleIds, config };
}
function loadEnabledTargets() {
if (targetsCache) return targetsCache;
targetsCache = stmts.listEnabledWebhookTargets.all().map(normalizeTarget);
return targetsCache;
}
/**
* Build the HTTP request for a target + alert: resolved URL, provider-native
* serialized body, and headers (provider auth headers, plus custom headers and
* an optional HMAC-SHA256 signature for the generic family). Exported for tests.
*/
function buildRequest(target, alert) {
const url = resolveUrl(target);
if (!url) throw new Error(`no URL resolved for webhook type "${target.type}"`);
const payload = formatPayload(target.type, alert, target.config || {});
const body = JSON.stringify(payload);
const headers = {
"Content-Type": "application/json",
"User-Agent": "claude-code-agent-monitor/webhooks",
...resolveAuthHeaders(target),
};
if (isGenericFamily(target.type)) {
if (target.headers && typeof target.headers === "object") {
for (const [k, v] of Object.entries(target.headers)) {
if (typeof k !== "string" || typeof v !== "string") continue;
// Never let a custom header clobber Content-Type or the signature.
const lower = k.toLowerCase();
if (lower === "content-type" || lower === "x-webhook-signature") continue;
headers[k] = v;
}
}
if (target.secret) {
const ts = new Date().toISOString();
const sig = crypto.createHmac("sha256", target.secret).update(`${ts}.${body}`).digest("hex");
headers["X-Webhook-Timestamp"] = ts;
headers["X-Webhook-Signature"] = `sha256=${sig}`;
}
}
return { url, body, headers };
}
// ── Delivery ────────────────────────────────────────────────────────────────
function sleep(ms) {
return new Promise((resolve) => {
const t = setTimeout(resolve, ms);
if (t.unref) t.unref();
});
}
async function postOnce(url, body, headers) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
if (timer.unref) timer.unref();
try {
const res = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
redirect: "follow",
});
// Read the response body — some providers (Splunk On-Call) signal failure
// in the body despite a 200, so deliver() may need to inspect it. Also
// frees the socket promptly. (Named distinctly from the `body` param.)
let responseBody = "";
try {
responseBody = await res.text();
} catch {
/* body read is best-effort */
}
return {
ok: res.status >= 200 && res.status < 300,
status: res.status,
error: null,
body: responseBody,
};
} catch (err) {
const timedOut = err?.name === "AbortError";
return {
ok: false,
status: null,
error: timedOut ? "timeout" : err?.message || "network error",
body: "",
};
} finally {
clearTimeout(timer);
}
}
function recordDelivery(target, alertId, { status, statusCode, attempts, error }) {
try {
stmts.insertWebhookDelivery.run(
target.id,
target.name,
target.type,
alertId == null ? null : alertId,
status,
statusCode == null ? null : statusCode,
attempts,
error == null ? null : truncate(error, 500)
);
stmts.pruneWebhookDeliveries.run();
} catch (err) {
console.warn("[WEBHOOK] delivery log write failed:", err?.message || err);
}
}
/**
* Deliver one alert to one target with bounded retry. Retries on transport
* errors, HTTP 429, and 5xx; gives up immediately on other 4xx (misconfigured
* URL / bad payload won't fix themselves). Always records the outcome and
* never throws. Returns `{ ok, status, attempts, error }`.
*/
async function deliver(target, alert) {
let built;
try {
built = buildRequest(target, alert);
} catch (err) {
recordDelivery(target, alert.id, {
status: "failed",
statusCode: null,
attempts: 0,
error: `request build failed: ${err?.message || err}`,
});
return { ok: false, status: null, attempts: 0, error: "request build failed" };
}
let attempts = 0;
let status = null;
let error = null;
const verifyResponse = PROVIDERS[target.type]?.verifyResponse;
while (attempts < MAX_ATTEMPTS) {
attempts += 1;
const res = await postOnce(built.url, built.body, built.headers);
status = res.status;
error = res.error;
if (res.ok) {
// Some providers (Splunk On-Call) return 200 even on rejection — let the
// provider veto a "successful" status by inspecting the response body.
const verdict = verifyResponse ? verifyResponse(res.body) : { ok: true };
if (verdict.ok) {
recordDelivery(target, alert.id, {
status: "success",
statusCode: status,
attempts,
error: null,
});
return { ok: true, status, attempts };
}
// A logical rejection won't fix on retry — fail immediately.
error = verdict.error || "provider reported failure";
break;
}
const retryable = status == null || status === 429 || status >= 500;
if (!retryable || attempts >= MAX_ATTEMPTS) break;
await sleep(RETRY_BASE_MS * attempts);
}
recordDelivery(target, alert.id, {
status: "failed",
statusCode: status,
attempts,
error: error || (status ? `HTTP ${status}` : "request failed"),
});
return {
ok: false,
status,
attempts,
error: error || (status ? `HTTP ${status}` : "request failed"),
};
}
/** A target receives an alert when it has no rule scope, or the alert's rule is in scope. */
function targetAppliesTo(target, alert) {
if (!Array.isArray(target.rule_ids) || target.rule_ids.length === 0) return true;
return target.rule_ids.includes(alert.rule_id);
}
/**
* Fan an alert out to every enabled, in-scope target. Returns a promise that
* settles when all deliveries finish (used by tests); callers in the alert
* path invoke it fire-and-forget. Never rejects.
*/
function dispatchAlert(alert) {
let targets;
try {
targets = loadEnabledTargets();
} catch (err) {
console.warn("[WEBHOOK] target load failed:", err?.message || err);
return Promise.resolve([]);
}
const applicable = targets.filter((t) => {
try {
return targetAppliesTo(t, alert);
} catch {
return false;
}
});
if (applicable.length === 0) return Promise.resolve([]);
return Promise.allSettled(applicable.map((t) => deliver(t, alert)));
}
/**
* Send a synthetic test alert to a single (already DB-loaded, un-redacted)
* target. Awaits the result so the route can report success/failure inline.
*/
function sendTest(target) {
const alert = {
id: null,
rule_id: null,
rule_name: "Webhook test",
rule_type: "test",
session_id: null,
agent_id: null,
message: `Test notification from Claude Code Agent Monitor to "${target.name}". If you can read this, delivery works.`,
details: { test: true, target: target.name, type: target.type },
triggered_at: new Date().toISOString(),
};
return deliver(target, alert);
}
module.exports = {
PROVIDERS,
WEBHOOK_TYPES,
invalidateWebhookCache,
loadEnabledTargets,
normalizeTarget,
formatPayload,
buildRequest,
deliver,
dispatchAlert,
sendTest,
targetAppliesTo,
};
+807
View File
@@ -0,0 +1,807 @@
/**
* Workflow-tool run ingestion.
*
* The Claude Code "Workflow" tool (and self-paced /loop) spawn fleets of inner
* sub-agents that emit NO hooks so hook-based ingestion can never see them.
* Everything lives on disk under the launching session's transcript folder:
*
* <projects>/<enc-cwd>/<sessionId>/
* workflows/
* scripts/<name>-wf_<runId>.js written at LAUNCH
* wf_<runId>.json run journal, written at COMPLETION
* subagents/workflows/<runId>/
* agent-<agentId>.jsonl one transcript per inner agent
* agent-<agentId>.meta.json
*
* The run journal is the source of truth for a completed run: identity,
* lifecycle, aggregates (agentCount/totalTokens/totalToolCalls), phases[], and
* workflowProgress[] a MIXED log of `type:"workflow_phase"` markers and
* `type:"workflow_agent"` entries. Each workflow_agent entry carries agentId,
* state ("done"/"error"/), label, phaseTitle, tokens, toolCalls, durationMs,
* etc., and its agentId is the EXACT agent-<agentId>.jsonl basename in the
* per-run nested dir above. Because the journal is terminal-only, a running
* workflow is detected from its launch script and replaced by the journal
* record on completion (idempotent upsert by run_id).
*
* Inner agents are linked into the existing agents table via the same
* `${sessionId}-jsonl-<agentId>` id scheme that importSubagentFromJsonl uses,
* so ingestion CONVERGES with any prior subagent import (no duplicate rows).
* Per-agent token/tool/duration metrics come from the journal's progress[]
* JSON this module never writes token_usage, so it cannot double-count.
*
* All functions are fail-safe: a malformed/partial journal throws only locally
* and is skipped; ingestion never blocks or breaks hook handling.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
// Lazy-required to avoid a require cycle (import-history → db → … ) and to keep
// startup cheap; mirrors how server/index.js lazy-requires import helpers.
function importHistory() {
return require("../../scripts/import-history");
}
let claudeHome = null;
function getClaudeHomeLib() {
if (!claudeHome) claudeHome = require("./claude-home");
return claudeHome;
}
/**
* Canonical run id derived from a journal/script filename. Both
* `wf_<runId>.json` and `<name>-wf_<runId>.js` reduce to the same `wf_<runId>`
* token so a launch-detected "running" row and its later journal reconcile on
* the same key.
*/
function extractRunId(filename) {
const base = path.basename(filename).replace(/\.(json|js)$/i, "");
const m = base.match(/wf_[A-Za-z0-9_-]+$/);
return m ? m[0] : base;
}
/** Workflow name from a launch-script basename: strip the `-wf_<runId>` tail. */
function nameFromScript(filename) {
const base = path.basename(filename).replace(/\.js$/i, "");
return base.replace(/-?wf_[A-Za-z0-9_-]+$/, "") || base;
}
function toIso(value) {
if (value == null) return null;
if (typeof value === "number") {
try {
return new Date(value).toISOString();
} catch {
return null;
}
}
return String(value);
}
/** Map a journal progress `state` to an agents.status value. */
function mapState(state) {
switch (String(state || "").toLowerCase()) {
case "error":
case "failed":
return "error";
case "running":
case "working":
case "active":
case "in_progress":
case "queued":
return "working";
case "done":
case "completed":
case "success":
return "completed";
default:
return "completed";
}
}
// Token fields carried on a parsed-subagent bucket (camelCase, matching
// writeSessionTokens). Used to fold inner-agent usage into the session's cost.
const TOKEN_FIELDS = [
"input",
"output",
"cacheRead",
"cacheWrite",
"cacheWrite1h",
"webSearch",
"webFetch",
"codeExec",
];
/**
* Merge a parsed agent's tokensByModel into a session-level accumulator, keyed
* by (model, speed, geo) with the service_tier forced to "workflow". This
* namespaces workflow spend into its own token_usage bucket so it never
* collides with or clobbers the main-transcript writer's rows, while still
* being summed per-model by the cost calculator. Inner agents are sidechain
* contexts whose usage is NOT in the parent transcript, so this is additive,
* not double-counting (same model as combineSessionTokens for subagents).
*/
function mergeWorkflowTokens(dst, src) {
for (const b of Object.values(src || {})) {
if (!b || !b.model) continue;
const key = `${b.model}|${b.speed}|${b.geo}|workflow`;
if (!dst[key]) {
dst[key] = {
model: b.model,
speed: b.speed,
geo: b.geo,
tier: "workflow",
};
for (const f of TOKEN_FIELDS) dst[key][f] = 0;
}
for (const f of TOKEN_FIELDS) dst[key][f] += b[f] || 0;
}
}
/**
* Resolve a session's transcript JSONL path from a session-like row. Prefers an
* explicit transcript_path; otherwise derives it from (id, cwd) via claude-home.
*/
function resolveTranscriptPath(session) {
if (session && session.transcript_path) return session.transcript_path;
if (session && session.id && session.cwd) {
try {
return getClaudeHomeLib().getTranscriptPath(session.id, session.cwd);
} catch {
return null;
}
}
return null;
}
/**
* Locate a session's workflow artifacts from its transcript JSONL path.
* Workflows live at `<dir>/<sessionId>/workflows/` next to
* `<dir>/<sessionId>.jsonl`; inner-agent transcripts are resolved per-run via
* agentsDirForRun(sessionDir, runId).
*
* @returns {{ sessionDir: string|null, workflowsDir: string|null,
* journals: string[], scripts: string[] }}
*/
function findSessionWorkflows(transcriptPath) {
const empty = {
sessionDir: null,
workflowsDir: null,
journals: [],
scripts: [],
liveRuns: [],
};
if (!transcriptPath) return empty;
const dir = path.dirname(transcriptPath);
const sessionId = path.basename(transcriptPath, ".jsonl");
const sessionDir = path.join(dir, sessionId);
const workflowsDir = path.join(sessionDir, "workflows");
const journals = [];
const scripts = [];
try {
if (fs.existsSync(workflowsDir)) {
for (const f of fs.readdirSync(workflowsDir)) {
if (f.startsWith("wf_") && f.endsWith(".json")) journals.push(path.join(workflowsDir, f));
}
const scriptsDir = path.join(workflowsDir, "scripts");
if (fs.existsSync(scriptsDir)) {
for (const f of fs.readdirSync(scriptsDir)) {
if (f.endsWith(".js")) scripts.push(path.join(scriptsDir, f));
}
}
}
} catch {
/* non-fatal — partial dir during a live run */
}
// Live per-run dirs: <sessionDir>/subagents/workflows/<runId>/ — present while
// a workflow is still running (journal.jsonl + growing agent-*.jsonl), before
// the terminal wf_<runId>.json journal is written.
const liveRuns = [];
try {
const base = path.join(sessionDir, "subagents", "workflows");
if (fs.existsSync(base)) {
for (const d of fs.readdirSync(base, { withFileTypes: true })) {
if (d.isDirectory()) liveRuns.push({ runId: d.name, dir: path.join(base, d.name) });
}
}
} catch {
/* non-fatal */
}
return { sessionDir, workflowsDir, journals, scripts, liveRuns };
}
/**
* Per-run inner-agent transcript directory. The Workflow tool writes each
* fleet's agents under `<sessionId>/subagents/workflows/<runId>/agent-*.jsonl`
* (NOT the session's top-level subagents/ dir).
*/
function agentsDirForRun(sessionDir, runId) {
return path.join(sessionDir, "subagents", "workflows", runId);
}
/** Read + normalize a run journal file. Returns null on any parse failure. */
function parseWorkflowJournal(journalPath) {
let raw;
try {
raw = fs.readFileSync(journalPath, "utf8");
} catch {
return null;
}
let j;
try {
j = JSON.parse(raw);
} catch {
return null;
}
const runId = extractRunId(journalPath) || j.runId || null;
if (!runId) return null;
const startedAt = toIso(j.startTime != null ? j.startTime : j.startedAt);
const durationMs = Number.isFinite(j.durationMs) ? j.durationMs : null;
let endedAt = toIso(j.endTime != null ? j.endTime : j.endedAt);
if (!endedAt && startedAt && durationMs != null) {
const t = Date.parse(startedAt);
if (!Number.isNaN(t)) endedAt = new Date(t + durationMs).toISOString();
}
const progress = Array.isArray(j.workflowProgress)
? j.workflowProgress
: Array.isArray(j.progress)
? j.progress
: [];
return {
runId,
taskId: j.taskId || null,
name: j.workflowName || j.name || nameFromScript(journalPath),
status: String(j.status || "completed"),
defaultModel: j.defaultModel || null,
startedAt,
endedAt,
durationMs,
agentCount: Number.isFinite(j.agentCount)
? j.agentCount
: progress.filter((e) => e && e.type === "workflow_agent").length,
totalTokens: Number.isFinite(j.totalTokens) ? j.totalTokens : 0,
totalToolCalls: Number.isFinite(j.totalToolCalls) ? j.totalToolCalls : 0,
phases: Array.isArray(j.phases) ? j.phases : [],
progress,
journalPath,
};
}
/**
* Ingest one parsed journal: upsert the workflow row, then link/create each
* inner agent. Returns the upserted workflow row, or null on failure.
*/
async function ingestWorkflowJournal(dbModule, sessionId, journal, opts = {}) {
const { stmts } = dbModule;
const mainAgentId = `${sessionId}-main`;
const ih = importHistory();
// Inner-agent transcripts live in a per-run nested dir, not the session's
// top-level subagents/. opts.sessionDir is the session transcript folder.
const agentDir = opts.sessionDir ? agentsDirForRun(opts.sessionDir, journal.runId) : null;
// Accumulate inner-agent token usage (real input/output/cache split from each
// transcript) so the run's spend can be folded into the session's cost.
const runTokens = {};
stmts.upsertWorkflow.run(
journal.runId,
sessionId,
journal.taskId,
journal.name,
journal.status,
journal.defaultModel,
journal.startedAt,
journal.endedAt,
journal.durationMs,
journal.agentCount,
journal.totalTokens,
journal.totalToolCalls,
JSON.stringify(journal.phases),
JSON.stringify(journal.progress),
opts.scriptPath || null,
journal.journalPath || null,
"journal"
);
// Only `workflow_agent` entries are real agents; `workflow_phase` entries are
// phase markers (kept in progress[] for the phase chips, skipped here).
const agentEntries = journal.progress.filter(
(e) => e && e.type === "workflow_agent" && e.agentId
);
for (const entry of agentEntries) {
const agentId = entry.agentId;
const jsonlId = `${sessionId}-jsonl-${agentId}`;
const status = mapState(entry.state);
const phase = entry.phaseTitle || null;
// subagent_type: prefer the label's prefix (e.g. "scout:starship" → "scout")
// for nicer grouping; otherwise the generic workflow-subagent type.
const subType =
(entry.label && entry.label.includes(":") ? entry.label.split(":")[0] : null) ||
entry.agentType ||
"workflow-subagent";
// Prefer parsing the real transcript so tool events + metadata land via the
// shared importer (idempotent, dedups by tool_use_id). Fall back to a
// minimal row built from the journal entry if the file is gone.
let parsed = null;
if (agentDir) {
const subPath = path.join(agentDir, `agent-${agentId}.jsonl`);
if (fs.existsSync(subPath)) {
try {
parsed = await ih.parseSubagentFile(subPath);
} catch {
parsed = null;
}
}
}
try {
if (parsed) {
ih.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, parsed);
mergeWorkflowTokens(runTokens, parsed.tokensByModel);
} else if (!stmts.getAgent.get(jsonlId)) {
stmts.insertAgent.run(
jsonlId,
sessionId,
entry.label || `Subagent ${String(agentId).slice(0, 8)}`,
"subagent",
subType,
status,
entry.label || entry.promptPreview || null,
mainAgentId,
JSON.stringify({
imported: true,
source: "workflow",
workflow_run_id: journal.runId,
model: entry.model || null,
tokens: entry.tokens || 0,
tool_calls: entry.toolCalls || 0,
})
);
}
// Stamp the workflow linkage + journal-authoritative status/phase.
stmts.setAgentWorkflow.run(journal.runId, phase, status, jsonlId);
} catch {
/* one bad agent must not abort the whole run ingest */
}
}
return { row: stmts.getWorkflow.get(journal.runId), tokens: runTokens };
}
function shortLabel(s) {
if (!s) return null;
const first = String(s).split("\n")[0].trim();
return first.length > 80 ? first.slice(0, 79) + "…" : first;
}
function safeStringify(v) {
if (v == null) return null;
if (typeof v === "string") return v;
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
function bucketTotal(tokensByModel) {
let n = 0;
for (const b of Object.values(tokensByModel || {})) {
n +=
(b.input || 0) +
(b.output || 0) +
(b.cacheRead || 0) +
(b.cacheWrite || 0) +
(b.cacheWrite1h || 0);
}
return n;
}
/**
* Live ingest for a RUNNING workflow before its terminal wf_<runId>.json
* exists. Builds progress[] + aggregates in real time from the streaming
* `<runDir>/journal.jsonl` (started/result events per agent) plus the growing
* `<runDir>/agent-<id>.jsonl` transcripts (real token/tool/duration usage via
* parseSubagentFile). Phase/label aren't available live (those come from the
* terminal journal), so phaseTitle is null and label falls back to the agent's
* prompt. The fast poll re-runs this as the files grow, so tokens/tools/agents
* update live. Returns { row, tokens } or null.
*/
async function ingestLiveWorkflow(dbModule, sessionId, sessionDir, runId, scriptPath) {
const { stmts } = dbModule;
const mainAgentId = `${sessionId}-main`;
const ih = importHistory();
const dir = agentsDirForRun(sessionDir, runId);
if (!fs.existsSync(dir)) return null;
// Streaming journal: which agents started / finished (+ their result payload).
const started = new Set();
const doneResults = new Map();
try {
const jj = path.join(dir, "journal.jsonl");
if (fs.existsSync(jj)) {
for (const line of fs.readFileSync(jj, "utf8").split("\n")) {
if (!line.trim()) continue;
let o;
try {
o = JSON.parse(line);
} catch {
continue;
}
if (!o || !o.agentId) continue;
if (o.type === "started") started.add(o.agentId);
else if (o.type === "result") doneResults.set(o.agentId, o.result);
}
}
} catch {
/* ignore */
}
let agentFiles = [];
try {
agentFiles = fs.readdirSync(dir).filter((f) => f.startsWith("agent-") && f.endsWith(".jsonl"));
} catch {
return null;
}
if (agentFiles.length === 0 && started.size === 0) return null;
const progress = [];
const runTokens = {};
let totalTokens = 0;
let totalToolCalls = 0;
let earliest = null;
let latest = null;
let model = null;
for (const f of agentFiles) {
const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/, "");
let parsed = null;
try {
parsed = await ih.parseSubagentFile(path.join(dir, f));
} catch {
parsed = null;
}
const done = doneResults.has(agentId);
const state = done ? "done" : "running";
const aTok = parsed ? bucketTotal(parsed.tokensByModel) : 0;
const tools = parsed && parsed.toolNames ? parsed.toolNames : [];
const startedAt = parsed && parsed.startedAt ? parsed.startedAt : null;
const endedAt = parsed && parsed.endedAt ? parsed.endedAt : null;
const durationMs = startedAt && endedAt ? Date.parse(endedAt) - Date.parse(startedAt) : null;
const label = parsed && parsed.task ? shortLabel(parsed.task) : null;
if (parsed && parsed.model && !model) model = parsed.model;
totalTokens += aTok;
totalToolCalls += tools.length;
if (startedAt) {
const ts = Date.parse(startedAt);
if (!earliest || ts < earliest) earliest = ts;
}
if (endedAt) {
const ts = Date.parse(endedAt);
if (!latest || ts > latest) latest = ts;
}
progress.push({
type: "workflow_agent",
agentId,
label,
phaseTitle: null,
model: parsed ? parsed.model : null,
state,
tokens: aTok,
toolCalls: tools.length,
durationMs,
lastToolName: tools.length ? tools[tools.length - 1] : null,
promptPreview: parsed ? parsed.task : null,
resultPreview: done ? safeStringify(doneResults.get(agentId)) : null,
});
try {
const jsonlId = `${sessionId}-jsonl-${agentId}`;
if (parsed) {
ih.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, parsed);
mergeWorkflowTokens(runTokens, parsed.tokensByModel);
} else if (!stmts.getAgent.get(jsonlId)) {
stmts.insertAgent.run(
jsonlId,
sessionId,
label || `Subagent ${agentId.slice(0, 8)}`,
"subagent",
"workflow-subagent",
mapState(state),
label,
mainAgentId,
JSON.stringify({ imported: true, source: "workflow-live", workflow_run_id: runId })
);
}
stmts.setAgentWorkflow.run(runId, null, mapState(state), jsonlId);
} catch {
/* one bad agent must not abort the live ingest */
}
}
// Agents that have a `started` event but no transcript file yet (queued).
for (const agentId of started) {
if (agentFiles.includes(`agent-${agentId}.jsonl`)) continue;
progress.push({
type: "workflow_agent",
agentId,
label: null,
phaseTitle: null,
model: null,
state: doneResults.has(agentId) ? "done" : "running",
tokens: 0,
toolCalls: 0,
durationMs: null,
lastToolName: null,
});
}
let startedAtIso = earliest ? new Date(earliest).toISOString() : null;
if (!startedAtIso && scriptPath) {
try {
startedAtIso = new Date(fs.statSync(scriptPath).mtimeMs).toISOString();
} catch {
/* ignore */
}
}
const durationMs = earliest && latest ? latest - earliest : null;
stmts.upsertWorkflow.run(
runId,
sessionId,
null,
scriptPath ? nameFromScript(scriptPath) : runId,
"running",
model,
startedAtIso,
null,
durationMs,
progress.length,
totalTokens,
totalToolCalls,
null,
JSON.stringify(progress),
scriptPath || null,
null,
"live"
);
return { row: stmts.getWorkflow.get(runId), tokens: runTokens };
}
/**
* Detect running workflows: a launch script whose journal hasn't landed yet.
* Upsert a minimal `running` row so the UI shows it before completion. Skips
* runs that already have a completed/error row (the journal won.) Returns the
* upserted rows.
*/
function detectRunningWorkflows(dbModule, sessionId, paths, handledRunIds) {
const { stmts } = dbModule;
const changed = [];
for (const scriptPath of paths.scripts) {
const runId = extractRunId(scriptPath);
if (!runId || handledRunIds.has(runId)) continue;
const existing = stmts.getWorkflow.get(runId);
if (existing && existing.status !== "running") continue; // journal already won
let startedAt = null;
let agentCount = 0;
try {
const st = fs.statSync(scriptPath);
startedAt = new Date(st.mtimeMs).toISOString();
} catch {
/* ignore */
}
// Best-effort fleet size: inner-agent transcripts in this run's nested dir.
try {
const agentDir = paths.sessionDir ? agentsDirForRun(paths.sessionDir, runId) : null;
if (agentDir && fs.existsSync(agentDir)) {
agentCount = fs
.readdirSync(agentDir)
.filter((f) => f.startsWith("agent-") && f.endsWith(".jsonl")).length;
}
} catch {
/* ignore */
}
stmts.upsertWorkflow.run(
runId,
sessionId,
null,
nameFromScript(scriptPath),
"running",
null,
startedAt,
null,
null,
agentCount,
0,
0,
null,
null,
scriptPath,
null,
"live"
);
changed.push(stmts.getWorkflow.get(runId));
}
return changed;
}
/**
* Ingest every workflow artifact for one session: completed journals first,
* then running detection for journal-less launch scripts.
*
* @param {object} dbModule - { db, stmts }
* @param {{id: string, transcript_path?: string, cwd?: string}} session
* @returns {Promise<object[]>} the workflow rows that were inserted/updated
*/
async function ingestWorkflowsForSession(dbModule, session) {
const sessionId = session && session.id;
if (!sessionId) return [];
const transcriptPath = resolveTranscriptPath(session);
if (!transcriptPath) return [];
const paths = findSessionWorkflows(transcriptPath);
if (paths.journals.length === 0 && paths.scripts.length === 0 && paths.liveRuns.length === 0) {
return [];
}
const changed = [];
const journalRunIds = new Set();
// Session-wide accumulator of inner-agent token usage across all runs, so the
// session's cost includes workflow spend. Recomputed in full each call (all
// journals are re-parsed) → writeSessionTokens replace semantics make it
// idempotent (no double-count across re-ingests).
const workflowTokens = {};
// Map runId → its launch script (so a journal row records script_path too).
const scriptByRun = new Map();
for (const s of paths.scripts) scriptByRun.set(extractRunId(s), s);
for (const journalPath of paths.journals) {
try {
const journal = parseWorkflowJournal(journalPath);
if (!journal) continue;
journalRunIds.add(journal.runId);
const res = await ingestWorkflowJournal(dbModule, sessionId, journal, {
sessionDir: paths.sessionDir,
scriptPath: scriptByRun.get(journal.runId) || null,
});
if (res && res.row) changed.push(res.row);
if (res && res.tokens) mergeWorkflowTokens(workflowTokens, res.tokens);
} catch {
/* skip malformed journal */
}
}
// Live runs (no terminal journal yet): build real-time progress + tokens from
// the streaming journal.jsonl + growing agent transcripts.
const liveHandled = new Set();
for (const lr of paths.liveRuns) {
if (journalRunIds.has(lr.runId)) continue; // terminal journal is authoritative
try {
const res = await ingestLiveWorkflow(
dbModule,
sessionId,
paths.sessionDir,
lr.runId,
scriptByRun.get(lr.runId) || null
);
if (res && res.row) {
changed.push(res.row);
liveHandled.add(lr.runId);
}
if (res && res.tokens) mergeWorkflowTokens(workflowTokens, res.tokens);
} catch {
/* non-fatal — partial live run */
}
}
try {
const handled = new Set([...journalRunIds, ...liveHandled]);
changed.push(...detectRunningWorkflows(dbModule, sessionId, paths, handled));
} catch {
/* non-fatal */
}
// Fold the workflow fleet's token usage into the session cost under a
// namespaced `workflow` service_tier (isolated from the main-transcript
// writer's buckets). getTokensBySession + calculateCost sum it per model.
try {
if (Object.keys(workflowTokens).length > 0) {
importHistory().writeSessionTokens(dbModule, sessionId, workflowTokens);
}
} catch {
/* non-fatal — cost folding must never break ingestion */
}
return changed;
}
/**
* One-time backfill: ingest workflow artifacts for every recorded session.
* Used by the legacy auto-import on first boot so historical completed
* workflows surface. Idempotent and fail-safe per session.
*
* @returns {Promise<{sessions: number, workflows: number}>}
*/
async function ingestAllWorkflows(dbModule) {
const { db } = dbModule;
let rows = [];
try {
rows = db.prepare("SELECT id, cwd, transcript_path FROM sessions").all();
} catch {
return { sessions: 0, workflows: 0 };
}
let sessions = 0;
let workflows = 0;
for (const row of rows) {
try {
const changed = await ingestWorkflowsForSession(dbModule, {
id: row.id,
cwd: row.cwd,
transcript_path: row.transcript_path,
});
if (changed.length > 0) {
sessions++;
workflows += changed.length;
}
} catch {
/* non-fatal — skip this session */
}
}
return { sessions, workflows };
}
/**
* Cheap change-fingerprint for a session's workflow artifacts: the newest mtime
* across its journals, launch scripts, and crucially for real-time the
* streaming files of any RUNNING run (journal.jsonl + agent-*.jsonl), so the
* poll re-ingests as a live workflow's tokens/agents grow. Per-file statting is
* bounded to runs without a terminal journal; completed runs contribute only
* their (stable) terminal-journal mtime. Returns 0 when nothing exists.
*/
function workflowsMaxMtime(transcriptPath) {
const { journals, scripts, liveRuns } = findSessionWorkflows(transcriptPath);
let max = 0;
const stat = (p) => {
try {
const m = fs.statSync(p).mtimeMs;
if (m > max) max = m;
} catch {
/* ignore */
}
};
for (const p of [...journals, ...scripts]) stat(p);
const completed = new Set(journals.map(extractRunId));
for (const lr of liveRuns) {
if (completed.has(lr.runId)) continue; // terminal journal mtime already counted
try {
for (const f of fs.readdirSync(lr.dir)) {
if (f.endsWith(".jsonl")) stat(path.join(lr.dir, f));
}
} catch {
/* ignore */
}
}
return max;
}
module.exports = {
ingestWorkflowsForSession,
ingestAllWorkflows,
ingestLiveWorkflow,
workflowsMaxMtime,
findSessionWorkflows,
parseWorkflowJournal,
ingestWorkflowJournal,
detectRunningWorkflows,
extractRunId,
nameFromScript,
mapState,
};
+631
View File
@@ -0,0 +1,631 @@
/**
* @file Git worktree management for lanes: creation, reset, removal, and the
* three-check destroy guard that stands between a mis-click and a user's real
* project directory. Every destructive function (resetWorktree, removeWorktree)
* verifies the lane against all three safety checks before touching git.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "ccam-lanes");
const PROTECTED_BRANCHES = new Set(["main", "master"]);
/**
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
* Treats zero exit code as success even if stderr has hints.
*
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
* that leak from parent processes. Without this, git operations on a worktree (where
* .git is a file, not a directory) fail with ".git/index: index file open failed:
* Not a directory" when run from within a git hook or from a shell that inherited
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
*/
async function git(cwd, args) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_COMMON_DIR;
delete env.GIT_OBJECT_DIRECTORY;
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete env.GIT_PREFIX;
delete env.GIT_NAMESPACE;
delete env.GIT_CONFIG_PARAMETERS;
// GIT_CONFIG_COUNT + GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n inject arbitrary git
// config into every invocation — including core.hooksPath, which would make an
// untrusted repo run our git commands' hooks. GIT_CONFIG_GLOBAL/SYSTEM do the
// same by redirecting which config files are read. All are scrubbed.
for (const name of Object.keys(env)) {
if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete env[name];
}
// Prevent credential prompts from hanging a background provisioning job
env.GIT_TERMINAL_PROMPT = "0";
try {
const result = await execFileAsync("git", args, {
cwd,
env,
maxBuffer: 8 * 1024 * 1024,
});
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {
const error = new Error(`git ${args[0]} failed`);
error.code = err.code;
error.git = { args, code: err.code, stderr: err.stderr };
throw error;
}
}
/**
* Check if a directory is a git repository.
*/
async function isGitRepo(dir) {
try {
await git(dir, ["rev-parse", "--git-dir"]);
return true;
} catch {
return false;
}
}
/**
* Resolve the base branch: try origin/<wanted>, then <wanted>, then HEAD.
*/
async function resolveBase(sourceRepo, wanted) {
// Try origin/<wanted>
try {
await git(sourceRepo, ["rev-parse", "--verify", "--quiet", `origin/${wanted}`]);
return wanted;
} catch {
// Fall through to next attempt
}
// Try <wanted>
try {
await git(sourceRepo, ["rev-parse", "--verify", "--quiet", wanted]);
return wanted;
} catch {
// Fall through to next attempt
}
// Fall back to current HEAD
const result = await git(sourceRepo, ["rev-parse", "--abbrev-ref", "HEAD"]);
return result.stdout.trim();
}
/**
* Slugify a title into a safe branch-name segment: lowercase, non-alphanumerics to `-`,
* collapsed, trimmed, max 40 chars. Throws EBADSLUG if result is empty.
*/
function slugify(text) {
const result = text
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
if (!result) {
const err = new Error("slug is empty");
err.code = "EBADSLUG";
throw err;
}
return result;
}
/**
* Parse `git worktree list --porcelain` output.
* Records are separated by blank lines, with keys like:
* - worktree <path>
* - branch refs/heads/<name>
* - locked (optional, bare line)
*/
async function listWorktrees(sourceRepo) {
const result = await git(sourceRepo, ["worktree", "list", "--porcelain"]);
const lines = result.stdout.split("\n");
const worktrees = [];
let current = {};
for (const line of lines) {
if (!line.trim()) {
if (current.path) {
worktrees.push(current);
current = {};
}
continue;
}
if (line.startsWith("worktree ")) {
current.path = line.slice("worktree ".length);
} else if (line.startsWith("branch ")) {
const branchPath = line.slice("branch ".length);
// Strip refs/heads/ prefix
current.branch = branchPath.replace(/^refs\/heads\//, "");
} else if (line === "locked") {
current.locked = true;
}
}
if (current.path) {
worktrees.push(current);
}
return worktrees;
}
/**
* Find which worktree (if any) has a given branch checked out.
*/
async function branchCheckedOutAt(sourceRepo, branch) {
const worktrees = await listWorktrees(sourceRepo);
const found = worktrees.find((w) => w.branch === branch);
return found ? found.path : null;
}
/**
* Create a new worktree, or add an existing branch to a new worktree.
* - If branch is already checked out elsewhere, throw EBRANCHBUSY.
* - If branch doesn't exist, create it with -b from base.
* - If branch exists, add without -b (reuse existing).
*/
async function addWorktree({ sourceRepo, dir, branch, base }) {
// Check if branch is already checked out elsewhere
const checkedOutAt = await branchCheckedOutAt(sourceRepo, branch);
if (checkedOutAt) {
const err = new Error(`branch ${branch} already checked out at ${checkedOutAt}`);
err.code = "EBRANCHBUSY";
err.checkedOutAt = checkedOutAt;
throw err;
}
// Ensure parent directory exists
fs.mkdirSync(path.dirname(dir), { recursive: true });
// Check if branch already exists
let branchExists = false;
try {
await git(sourceRepo, ["rev-parse", "--verify", "--quiet", branch]);
branchExists = true;
} catch {
// Branch doesn't exist, we'll create it with -b
}
if (branchExists) {
// Branch exists, use it
await git(sourceRepo, ["worktree", "add", dir, branch]);
return { dir, branch, created: false };
} else {
// Branch doesn't exist, create it from base
await git(sourceRepo, ["worktree", "add", "-b", branch, dir, base]);
return { dir, branch, created: true };
}
}
/**
* Delete a branch safely: never delete protected branches or falsy branch.
*/
async function deleteBranchSafely(sourceRepo, branch, baseBranch) {
if (!branch) {
return; // Branch is falsy, don't delete
}
if (PROTECTED_BRANCHES.has(branch)) {
return; // Protected branch
}
if (baseBranch && branch === baseBranch) {
return; // Never delete the base branch
}
try {
await git(sourceRepo, ["branch", "-D", branch]);
} catch {
// Ignore deletion failures
}
}
/**
* Check 1 on its own: only a dashboard-provisioned worktree may ever be
* destroyed. Shared with removeWorktree's prune path, which cannot run checks 2
* and 3 as written (there is no directory left to resolve) but must still refuse
* an adopted lane outright.
*/
function assertManaged(lane) {
if (lane.kind !== "managed") {
const err = new Error(`lane kind is ${lane.kind}, not managed`);
err.code = "ENOTMANAGED";
throw err;
}
}
/**
* Check 2 for a path that may no longer exist: is this lane's RECORDED cwd
* inside LANES_ROOT on a path boundary? Purely lexical after resolving
* LANES_ROOT itself, because a hand-deleted worktree cannot be realpath'd.
* assertDestroyable still realpaths a live cwd, which additionally defeats
* symlinks; this weaker form only ever gates operations that touch git
* bookkeeping, never a directory.
*/
function isInsideLanesRoot(cwd) {
let resolvedRoot;
try {
resolvedRoot = fs.realpathSync(LANES_ROOT);
} catch {
return false;
}
const relativePath = path.relative(resolvedRoot, path.resolve(cwd));
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}
/**
* Three checks for whether a lane can be safely destroyed:
* 1. kind must be "managed" (not "adopted")
* 2. cwd must resolve to a path inside LANES_ROOT
* 3. The path must be listed in git worktree list for the source repo
*/
async function assertDestroyable(lane) {
// Check 1: kind must be "managed"
assertManaged(lane);
// Check 2: cwd must be inside LANES_ROOT on a path boundary
let resolvedCwd;
let resolvedRoot;
try {
resolvedCwd = fs.realpathSync(lane.cwd);
} catch {
// Path doesn't exist, which means it's not a live worktree
const err = new Error(`lane cwd does not exist: ${lane.cwd}`);
err.code = "EOUTSIDEROOT";
throw err;
}
try {
resolvedRoot = fs.realpathSync(LANES_ROOT);
} catch {
// LANES_ROOT doesn't exist, so cwd can't be inside it
const err = new Error(`LANES_ROOT does not exist: ${LANES_ROOT}`);
err.code = "EOUTSIDEROOT";
throw err;
}
// Check that cwd is inside LANES_ROOT on a path boundary
const relativePath = path.relative(resolvedRoot, resolvedCwd);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
const err = new Error(`lane cwd is outside LANES_ROOT: ${resolvedCwd} not in ${resolvedRoot}`);
err.code = "EOUTSIDEROOT";
throw err;
}
// Check 3: path must be in worktree list.
// git keeps listing a hand-deleted worktree (as `prunable`), so realpath must
// be tolerated per entry: an entry we cannot resolve is simply not this lane.
// Throwing here failed reset/remove for every OTHER lane in the same repo with
// an ENOENT naming an unrelated directory.
const worktrees = await listWorktrees(lane.source_repo);
const exists = worktrees.some((w) => {
try {
return fs.realpathSync(w.path) === resolvedCwd;
} catch {
return false;
}
});
if (!exists) {
const err = new Error(`lane is not listed as a worktree in ${lane.source_repo}`);
err.code = "ENOTWORKTREE";
throw err;
}
}
/**
* Reset a worktree to its base branch: checkout base, reset hard, clean files,
* then reset the feature branch to the base. This uses git branch -f from a
* separate working directory context to avoid worktree association restrictions.
*
* Verifies the base branch exists BEFORE any mutations, and verifies the final
* state ends on the feature branch (not left on base or detached).
*/
async function resetWorktree(lane) {
// Safety check
await assertDestroyable(lane);
const { cwd, branch, source_repo: sourceRepo, base_branch: baseBranch } = lane;
// CRITICAL: Verify base branch exists BEFORE any mutations.
// If base doesn't exist, we can't safely reset anything.
try {
await git(cwd, ["rev-parse", "--verify", baseBranch]);
} catch {
const err = new Error(`base branch does not exist: ${baseBranch}`);
err.code = "ENOBASE";
throw err;
}
// Fetch and prune (tolerate failure if no remote)
try {
await git(cwd, ["fetch", "origin", "--prune"]);
} catch {
// Ignore: may not have a remote
}
// Checkout base. Should succeed now that we've verified it exists.
try {
await git(cwd, ["checkout", baseBranch]);
} catch {
// Doesn't exist locally, try to create from remote (may fail if no remote)
try {
await git(cwd, ["checkout", "-b", baseBranch, `origin/${baseBranch}`]);
} catch {
// If both failed but rev-parse passed, the branch exists but we can't check it out
// Try the reset anyway - it might work even if checkout failed
}
}
// Reset hard to base
await git(cwd, ["reset", "--hard", baseBranch]);
// Clean untracked files (but NOT ignored files, so -x is omitted)
await git(cwd, ["clean", "-fd"]);
// Reset the feature branch. Git worktrees prevent deletion/force-update of
// "their" branch, so we reset it in-place instead: checkout → reset hard.
// ponytail: worktree association blocks deletion, reset-in-place instead
try {
// Try to checkout the feature branch
await git(cwd, ["checkout", branch]);
// Reset the current branch (feat/branch) to base
await git(cwd, ["reset", "--hard", baseBranch]);
} catch {
// If checkout fails, the branch might not exist. Create it.
try {
await git(cwd, ["checkout", "-b", branch, baseBranch]);
} catch (err) {
// If both checkout and create failed, we're in trouble
throw err;
}
}
// CRITICAL: Verify we actually ended on the feature branch.
// If this fails, the reset succeeded but left us on the wrong branch.
const currentBranch = (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
if (currentBranch !== branch) {
const err = new Error(`reset ended on wrong branch: expected ${branch}, got ${currentBranch}`);
err.code = "ERESETBRANCH";
throw err;
}
// Try to clean up by deleting the branch from source repo (may fail if still in use)
await deleteBranchSafely(sourceRepo, branch, baseBranch);
}
/**
* Locate a worktree's administrative directory under the source repo's common
* dir (`<common>/worktrees/<name>`) by matching the `gitdir` file each entry
* points at against the worktree's cwd. That file's content is the absolute
* path to the worktree's OWN `.git` file, so its dirname is the worktree path
* this still works when that `.git` file is corrupt, since we only ever
* read it from the source repo's side. Returns null if no entry matches.
*/
async function findWorktreeAdminDir(sourceRepo, cwd) {
const common = (await git(sourceRepo, ["rev-parse", "--git-common-dir"])).stdout.trim();
const worktreesDir = path.join(path.resolve(sourceRepo, common), "worktrees");
let entries;
try {
entries = fs.readdirSync(worktreesDir);
} catch {
return null;
}
const resolvedCwd = path.resolve(cwd);
for (const name of entries) {
let pointer;
try {
pointer = fs.readFileSync(path.join(worktreesDir, name, "gitdir"), "utf8").trim();
} catch {
continue;
}
if (path.resolve(path.dirname(pointer)) === resolvedCwd) {
return path.join(worktreesDir, name);
}
}
return null;
}
/**
* Remove a worktree completely: unlock, remove, prune, delete branch.
*
* When the directory was deleted by hand there is nothing on disk to destroy,
* but git still registers the worktree and the branch so that case takes the
* prune path instead of the full three checks, which cannot resolve a path that
* no longer exists. Checks 1 and 2 still hold there (an adopted lane is refused
* outright; the recorded cwd must still be inside LANES_ROOT), and the operation
* touches only git bookkeeping in the source repo. Check 3 is what the prune
* replaces: a worktree git no longer lists needs no removal at all.
*/
async function removeWorktree(lane) {
const { cwd, branch, source_repo: sourceRepo, base_branch: baseBranch } = lane;
if (!fs.existsSync(cwd)) {
assertManaged(lane);
if (!isInsideLanesRoot(cwd)) {
const err = new Error(`lane cwd is outside LANES_ROOT: ${cwd} not in ${LANES_ROOT}`);
err.code = "EOUTSIDEROOT";
throw err;
}
// Drops git's record of the vanished worktree. A no-op when git never knew
// it, which leaves only the branch to clean up.
await git(sourceRepo, ["worktree", "prune"]);
const stillListed = (await listWorktrees(sourceRepo)).some(
(w) => path.resolve(w.path) === path.resolve(cwd)
);
if (stillListed) {
await git(sourceRepo, ["worktree", "remove", "--force", cwd]);
}
await deleteBranchSafely(sourceRepo, branch, baseBranch);
return;
}
// Safety check
await assertDestroyable(lane);
// Unlock (ignore failure)
try {
await git(sourceRepo, ["worktree", "unlock", cwd]);
} catch {
// Ignore
}
// Remove the worktree (force). Git validates the worktree's OWN `.git`
// pointer before it will touch it, and refuses outright (even with a
// second --force) when that pointer is corrupt — the three checks above
// already proved this is a real, managed worktree of this repo, so fall
// back to deregistering it directly from the source repo's bookkeeping
// rather than leaving the lane permanently stuck. This never touches the
// worktree directory itself — only `<sourceRepo>/.git/worktrees/<name>`.
try {
await git(sourceRepo, ["worktree", "remove", "--force", cwd]);
} catch (removeErr) {
const adminDir = await findWorktreeAdminDir(sourceRepo, cwd);
if (!adminDir) throw removeErr;
fs.rmSync(adminDir, { recursive: true, force: true });
}
// Prune dead worktree entries
await git(sourceRepo, ["worktree", "prune"]);
// Delete the branch safely
await deleteBranchSafely(sourceRepo, branch, baseBranch);
}
/**
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
* Lines starting with ?? are untracked; others are dirty.
*/
async function statusCounts(dir) {
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
let dirty = 0;
let untracked = 0;
for (const line of result.stdout.split("\n")) {
if (!line.trim()) continue;
if (line.startsWith("??")) {
untracked++;
} else {
dirty++;
}
}
// Get short commit hash
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
const head = headResult.stdout.trim();
return { dirty, untracked, head };
}
/**
* What a lane's working copy looks like right now: which branch it is on, the
* short HEAD, that commit's subject, and how much is uncommitted.
*
* Read-only and cheap, but it is three subprocesses, which is why it lives
* behind its own endpoint rather than inside the polled `GET /api/lanes`
* payload. A detached HEAD reports the literal `HEAD` that git returns the
* caller shows what git says rather than inventing a nicer word for it.
*/
async function gitFacts(dir) {
const { dirty, untracked, head } = await statusCounts(dir);
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
return { branch, head, subject, dirty, untracked };
}
/**
* Count the commits a destructive action would actually discard.
*
* With remotes configured: commits on no remote (`--not --remotes HEAD`).
*
* With NO remotes: the commits ahead of the lane's base branch
* (`<base>..HEAD`) the work that belongs to this lane. Counting the whole
* history instead made a freshly provisioned worktree in a local-only repo
* report every commit in the repo as unpushed and demand Force to discard
* commits a `reset --hard <base>` would never touch. The `no-remote` warning is
* what tells the user nothing is backed up.
*
* Falls back to the total commit count only when there is no usable base to
* measure against (an adopted lane has no `base_branch` at all).
* Returns 0 if the repository is corrupt or unborn.
*
* @param {string} dir - Working directory to count in.
* @param {string|null} [baseBranch] - The lane's base branch, when it has one.
*/
async function unpushedCount(dir, baseBranch = null) {
try {
// First check if there are any remotes
const remotesResult = await git(dir, ["remote"]);
const hasRemotes = !!remotesResult.stdout.trim();
if (hasRemotes) {
// Has remotes: count commits not on any remote
const result = await git(dir, ["rev-list", "--count", "--not", "--remotes", "HEAD"]);
return parseInt(result.stdout.trim(), 10);
}
if (baseBranch) {
try {
const result = await git(dir, ["rev-list", "--count", `${baseBranch}..HEAD`]);
return parseInt(result.stdout.trim(), 10);
} catch {
// Base branch is gone or never existed — fall through to the total.
}
}
// No remote and no usable base: every commit is at risk, so count them all.
const result = await git(dir, ["rev-list", "--count", "HEAD"]);
return parseInt(result.stdout.trim(), 10);
} catch {
// Repository error (unborn HEAD, corrupt, etc.)
return 0;
}
}
/**
* Check if the repository has no remotes configured at all.
* Returns true if `git remote` output is empty, false otherwise.
*/
async function hasNoRemotes(dir) {
try {
const result = await git(dir, ["remote"]);
return !result.stdout.trim();
} catch {
// Assume remotes exist if we can't query
return false;
}
}
module.exports = {
LANES_ROOT,
git,
isGitRepo,
resolveBase,
slugify,
listWorktrees,
branchCheckedOutAt,
addWorktree,
assertManaged,
isInsideLanesRoot,
assertDestroyable,
resetWorktree,
removeWorktree,
statusCounts,
gitFacts,
unpushedCount,
hasNoRemotes,
};
+55
View File
@@ -0,0 +1,55 @@
/**
* @file Supplementary OpenAPI fragments merged into the base spec by
* `createOpenApiSpec()` (server/openapi.js). Each domain fragment under
* `server/openapi-extra/` exports `{ tags, schemas, paths }`; this module
* deep-combines them into one `{ tags, schemas, paths }` object. Extra paths
* and schemas OVERRIDE base entries with the same key, so a comprehensive
* entry here can supersede a terser one in the base literal; tags are appended
* only when their `name` is not already present.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
// New endpoint groups (previously-undocumented gaps in the base spec).
const ccConfig = require("./openapi-extra/cc-config");
const run = require("./openapi-extra/run");
const lanes = require("./openapi-extra/lanes");
const push = require("./openapi-extra/push");
const misc = require("./openapi-extra/misc");
// Enriched overrides of already-documented endpoints — same operationId, tags,
// and request/response `$ref` schemas as the base, with added examples and
// richer descriptions. Listed last so they win the merge.
const overrideSessionsAgents = require("./openapi-extra/override-sessions-agents");
const overrideCore = require("./openapi-extra/override-core");
const overridePricingAlerts = require("./openapi-extra/override-pricing-alerts");
const overrideOps = require("./openapi-extra/override-ops");
/** Combine N fragments into a single { tags, schemas, paths }. Later fragments
* override earlier ones on path/schema key collisions; tags dedupe by name. */
function combine(...fragments) {
const out = { tags: [], schemas: {}, paths: {} };
const tagNames = new Set();
for (const frag of fragments) {
if (!frag) continue;
for (const tag of frag.tags || []) {
if (tag && !tagNames.has(tag.name)) {
tagNames.add(tag.name);
out.tags.push(tag);
}
}
Object.assign(out.schemas, frag.schemas || {});
Object.assign(out.paths, frag.paths || {});
}
return out;
}
module.exports = combine(
ccConfig,
run,
lanes,
push,
misc,
overrideSessionsAgents,
overrideCore,
overridePricingAlerts,
overrideOps
);
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
/**
* @file OpenAPI fragment for dashboard-managed git worktree lane provisioning
* and the confirmed, preflight-guarded reset, remove, and purge lifecycle API.
* It documents the asynchronous provisioning and destructive action contracts
* for the built-in Swagger and ReDoc surfaces, plus the read-only
* `LaneStageDetectionFields` schema for the stage-detection fields every lane
* response carries, and the idempotent `POST /api/lanes/ensure` lookup the
* Workspace page opens a directory with.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Lanes",
description: "Durable parallel-work lanes and dashboard-managed git worktrees",
},
];
const schemas = {
LaneWorktreeCreateRequest: {
type: "object",
required: ["sourceRepo"],
properties: {
sourceRepo: {
type: "string",
description: "Existing absolute path to the source git repository.",
example: "/Users/me/src/project",
},
title: {
type: "string",
description: "Human-readable lane title.",
example: "Criteria form",
},
base: {
type: "string",
description:
"Preferred base branch. Defaults to the LANE_BASE_BRANCH env var, or `main` when that is also unset.",
example: "main",
},
slug: {
type: "string",
description: "Optional branch/directory slug override.",
example: "criteria-form",
},
},
},
LaneEnsureRequest: {
type: "object",
required: ["cwd"],
properties: {
cwd: {
type: "string",
description:
"Absolute working directory to find or adopt a lane for. A lane whose own cwd is this path, or the closest path-boundary parent of it, is returned as-is.",
example: "/Users/me/src/project/packages/app",
},
title: {
type: "string",
description:
"Title for the lane if one has to be created; ignored when one already exists.",
example: "App package",
},
},
},
LaneDestructiveActionRequest: {
type: "object",
required: ["confirm", "expect"],
properties: {
confirm: { type: "boolean", enum: [true] },
force: {
type: "boolean",
description: "Required by reset/remove when unpushed commits exist.",
},
expect: {
type: "object",
description:
"Required complete facts returned by the preceding preflight: head, dirty, untracked, unpushed for reset/remove; sessions, events, tokenRows for purge. Differences return 409 ESTALE without destructive work.",
additionalProperties: true,
},
},
},
LaneStageDetectionFields: {
type: "object",
description:
"Fields the server's stage-detection heuristic (server/lib/stage-detect.js) adds to every lane returned by GET /api/lanes and GET /api/lanes/:id. An inferred stage is never evidence and never renders as done — see docs/LANES.md#stage-detection.",
properties: {
detected_stage: {
type: "string",
nullable: true,
description:
"Stage id inferred from ingested tool events, or null if no signal has been seen. Independent of the agent's own declared `stage`.",
},
detected_signal: {
type: "string",
nullable: true,
description:
"The tool-event signal that produced detected_stage, capped at 120 characters; null when detected_stage is null.",
},
detected: {
type: "boolean",
description:
"Present on each entry of pipeline_nodes. True for the inferred node and any node before it that carries no declared record; decorates that node's state without ever upgrading it to done.",
},
},
},
};
const paths = {
"/api/lanes/ensure": {
post: {
tags: ["Lanes"],
summary: "Find or adopt the lane owning a working directory",
description:
"Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
operationId: "ensureLane",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/LaneEnsureRequest" } },
},
},
responses: {
200: {
description: "An existing lane already owns that cwd.",
content: {
"application/json": {
schema: {
type: "object",
required: ["lane", "created"],
properties: {
lane: { type: "object", additionalProperties: true },
created: { type: "boolean", enum: [false] },
},
},
},
},
},
201: {
description: "No lane owned that cwd, so an adopted one was created.",
content: {
"application/json": {
schema: {
type: "object",
required: ["lane", "created"],
properties: {
lane: { type: "object", additionalProperties: true },
created: { type: "boolean", enum: [true] },
},
},
},
},
},
400: {
description: "cwd is missing or not an absolute path (EBADCWD).",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
403: {
description: "The browser request was not same-origin/loopback.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
"/api/lanes/worktree": {
post: {
tags: ["Lanes"],
summary: "Provision a managed git worktree lane",
description:
"Validates the absolute source repository, creates a managed lane in `provisioning` state, and returns immediately. Worktree creation continues under the lane lock; the existing `lane_update` broadcast reports either `idle` or `failed` with git stderr in `notes`.",
operationId: "createLaneWorktree",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/LaneWorktreeCreateRequest" },
},
},
},
responses: {
202: {
description: "Managed lane accepted for background provisioning.",
content: {
"application/json": {
schema: {
type: "object",
required: ["lane"],
properties: { lane: { type: "object", additionalProperties: true } },
},
},
},
},
400: {
description: "sourceRepo is relative, missing, or not a git repository.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
403: {
description: "The browser request was not same-origin/loopback.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
409: {
description:
"The computed worktree directory already belongs to a lane, or no unique directory was available after 50 attempts.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
"/api/lanes/{id}": {
patch: {
tags: ["Lanes"],
summary: "Partially update a lane",
description:
"Updates lane fields, including run_id; browser requests must pass the loopback same-origin guard. The provisioning-time facts kind, source_repo, slug and base_branch are NOT patchable — kind is check 1 of the destroy guard — and are silently ignored here.",
operationId: "updateLane",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: { type: "object", additionalProperties: true },
},
},
},
responses: {
200: { description: "Updated lane." },
400: { description: "kind was not one of adopted|managed (EBADKIND)." },
403: { description: "The browser request was not same-origin/loopback." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/preflight": {
get: {
tags: ["Lanes"],
summary: "Count facts before a destructive lane action",
operationId: "preflightLaneAction",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{
name: "action",
in: "query",
required: true,
schema: { type: "string", enum: ["reset", "remove", "purge"] },
},
],
responses: {
200: {
description:
"Current counted facts for the selected action. reset/remove additionally return blocked[] (hard blockers: adopted, missing, unreadable, unpushed-commits — the only one force overrides) and warnings[] (informational only, e.g. no-remote).",
},
400: { description: "Unknown action." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git": {
get: {
tags: ["Lanes"],
summary: "A lane's working-copy facts",
description:
"Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
operationId: "getLaneGitFacts",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: {
description:
"available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
},
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/{action}": {
post: {
tags: ["Lanes"],
summary: "Confirm a reset, managed-worktree removal, or session purge",
description:
"Actions run under the lane lock after waiting for the lane child's actual exit. A failed spawn is already exited because no child started. Reset requires a live managed worktree; remove tears down a managed worktree, prunes git's stale record when the directory was deleted by hand, or only forgets an adopted-lane row without touching its directory. Reset/remove require force when managed work has unpushed commits.",
operationId: "runLaneDestructiveAction",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{
name: "action",
in: "path",
required: true,
schema: { type: "string", enum: ["reset", "remove", "purge"] },
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/LaneDestructiveActionRequest" },
},
},
},
responses: {
200: { description: "Action completed; purge includes its deleted-row counts." },
400: {
description:
"Confirmation or complete expect facts missing, or the managed-worktree guard refused the target.",
},
403: { description: "The browser request was not same-origin/loopback." },
409: { description: "Preflight facts changed (ESTALE) or force is required (EUNPUSHED)." },
500: {
description:
"Git, run-exit timeout, or internal failure; git failures include error.stderr.",
},
},
},
},
};
module.exports = { tags, schemas, paths };
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
/**
* @file Enriched OpenAPI OVERRIDE operations for the core read/ingest endpoints
* that already exist in `server/openapi.js`:
*
* GET /api/events
* GET /api/events/facets
* GET /api/stats
* GET /api/analytics
* POST /api/hooks/event
*
* These operations are intentionally CONTRACT-IDENTICAL to the base spec. Every
* `operationId`, `tags` value, parameter name/`in`/schema, request-body `$ref`,
* and response `$ref` is copied verbatim from `server/openapi.js`. The ONLY
* additions here are richer prose `description`s, realistic per-parameter
* `example`s, and realistic media-type `example`s on request/response bodies
* none of which change the wire contract.
*
* This module exports the override-merge surface expected by the spec builder:
* - `tags`: [] (no new tags reuse the base Events/Stats/Analytics/Hooks tags)
* - `schemas`: {} (no new schemas reuse base `$ref`s only)
* - `paths`: the enriched override operations keyed by path
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
"use strict";
// No new tags. The base operations already belong to the Events / Stats /
// Analytics / Hooks tags; overriding those tag arrays here would risk drift.
const tags = [];
// No new schemas. Every response and request body below reuses an existing
// `#/components/schemas/...` `$ref` defined in `server/openapi.js`.
const schemas = {};
const paths = {
"/api/events": {
get: {
tags: ["Events"],
summary: "List events with multi-dimensional filtering",
operationId: "listEvents",
description:
"Returns a paginated, reverse-chronological slice of the `events` table " +
"(ordered by `created_at DESC, id DESC`) together with the total row count " +
"matching the active filters, so the UI can drive a paginator without a " +
"second request.\n\n" +
"All four entity filters — `event_type`, `tool_name`, `agent_id`, and " +
"`session_id` — accept a **comma-separated list (CSV)** of values and match " +
"with `IN (...)` semantics: passing `event_type=Stop,PreToolUse` returns rows " +
"whose `event_type` is either `Stop` OR `PreToolUse`. Values are trimmed and " +
"blank entries are dropped. Filters are combined with one another using AND.\n\n" +
"`q` performs a case-insensitive substring (`LIKE %q%`) search across the " +
"`summary`, `tool_name`, and the JSON-encoded `data` columns. `from`/`to` are " +
"inclusive ISO-8601 datetime bounds on `created_at`; unparseable values are " +
"ignored rather than rejected. `limit` is clamped to 1500 (default 50) and " +
"`offset` is clamped to >= 0 (default 0).\n\n" +
"Note: each returned event's `data` field is a **JSON-encoded string**, not a " +
"nested object — callers must `JSON.parse` it to inspect the payload.",
parameters: [
{
in: "query",
name: "event_type",
description:
"Comma-separated (CSV) list of `event_type` values; matched with IN semantics " +
"(OR within the list). Common values: PreToolUse, PostToolUse, Stop, " +
"SubagentStop, Notification, SessionStart, SessionEnd.",
schema: { type: "string" },
example: "Stop,PreToolUse",
},
{
in: "query",
name: "tool_name",
description:
"Comma-separated (CSV) list of `tool_name` values; matched with IN semantics " +
"(OR within the list). Common values: Bash, Edit, Read, Write, Grep, Glob, Task.",
schema: { type: "string" },
example: "Bash,Edit",
},
{
in: "query",
name: "agent_id",
description:
"Comma-separated (CSV) list of `agent_id` values; matched with IN semantics. " +
"The main agent of a session uses the id `<session_id>-main`.",
schema: { type: "string" },
example: "8f3c2a10-1b2c-4d5e-9f80-112233445566-main",
},
{
in: "query",
name: "session_id",
description:
"Comma-separated (CSV) list of `session_id` values; matched with IN semantics " +
"(OR within the list).",
schema: { type: "string" },
example: "8f3c2a10-1b2c-4d5e-9f80-112233445566,2a7d9e44-3c1f-4a6b-bc20-aabbccddeeff",
},
{
in: "query",
name: "q",
description:
"Case-insensitive substring search (`LIKE %q%`) applied across the `summary`, " +
"`tool_name`, and JSON-encoded `data` columns.",
schema: { type: "string" },
example: "curl",
},
{
in: "query",
name: "from",
description:
"ISO-8601 datetime lower bound (inclusive) on `created_at`. Unparseable values " +
"are ignored.",
schema: { type: "string", format: "date-time" },
example: "2026-06-25T00:00:00.000Z",
},
{
in: "query",
name: "to",
description:
"ISO-8601 datetime upper bound (inclusive) on `created_at`. Unparseable values " +
"are ignored.",
schema: { type: "string", format: "date-time" },
example: "2026-06-26T00:00:00.000Z",
},
{
$ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
},
{
in: "query",
name: "limit",
description: "Max rows to return; clamped to 1500 (default 50).",
schema: { type: "integer", minimum: 1, maximum: 500, default: 50 },
example: 50,
},
{ $ref: "#/components/parameters/OffsetQuery" },
],
responses: {
200: {
description: "Event list with total count for pagination",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/EventsListResponse" },
example: {
events: [
{
id: 48213,
session_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566",
agent_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566-main",
event_type: "PreToolUse",
tool_name: "Bash",
summary: "Bash: curl -s https://api.example.com/health",
data: '{"session_id":"8f3c2a10-1b2c-4d5e-9f80-112233445566","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"curl -s https://api.example.com/health","description":"Check upstream health"},"cwd":"/Users/dev/project"}',
created_at: "2026-06-25T18:42:07.512Z",
},
{
id: 48212,
session_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566",
agent_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566-main",
event_type: "Stop",
tool_name: null,
summary: "Session finished responding",
data: '{"session_id":"8f3c2a10-1b2c-4d5e-9f80-112233445566","hook_event_name":"Stop"}',
created_at: "2026-06-25T18:41:55.004Z",
},
],
limit: 50,
offset: 0,
total: 1342,
},
},
},
},
},
},
},
"/api/events/facets": {
get: {
tags: ["Events"],
summary: "Distinct event_type and tool_name values available in the DB",
operationId: "listEventFacets",
description:
"Returns the distinct, non-null `event_type` and `tool_name` values currently " +
"present in the `events` table, each sorted alphabetically. The UI uses this to " +
"populate the filter dropdowns on the Events screen without hardcoding the set of " +
"tools or hook types — so the lists automatically reflect whatever has actually " +
"been ingested. Both arrays are independent and may be empty when the table holds " +
"no matching rows.",
responses: {
200: {
description: "Facet values for populating filter dropdowns",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/EventsFacetsResponse" },
example: {
event_types: [
"Notification",
"PostToolUse",
"PreToolUse",
"SessionEnd",
"SessionStart",
"Stop",
"SubagentStop",
],
tool_names: ["Bash", "Edit", "Glob", "Grep", "Read", "Task", "Write"],
},
},
},
},
},
},
},
"/api/stats": {
get: {
tags: ["Stats"],
summary: "Get aggregate dashboard stats",
operationId: "getStats",
description:
"Returns the headline counters shown across the top of the dashboard: total and " +
"active session/agent counts, total event count, today's event count, and the " +
"current number of live WebSocket connections.\n\n" +
"The overview counters are spread at the top level of the response object. Two " +
"additional maps, `agents_by_status` and `sessions_by_status`, break the counts " +
"down by lifecycle status (e.g. agents: working/waiting/completed/error; sessions: " +
"active/completed/error/abandoned). **Statuses with a zero count are omitted from " +
"these maps**, so callers must not assume every status key is present.\n\n" +
"`events_today` is computed in the caller's local day. Pass `tz_offset` as the " +
"minutes value from JavaScript's `Date.prototype.getTimezoneOffset()` (for example " +
"`420` for US Pacific Daylight Time, `300` for US Eastern Daylight Time, `0` for " +
"UTC). When omitted or non-numeric, the server falls back to UTC (offset 0).",
parameters: [
{
in: "query",
name: "tz_offset",
description:
"Caller timezone offset in MINUTES, as returned by JS " +
"`Date.prototype.getTimezoneOffset()` (e.g. 420 for PDT, 300 for EDT, 0 for " +
"UTC). Used to bucket `events_today` into the caller's local day. Defaults to " +
"0 (UTC) when omitted or non-numeric.",
schema: { type: "integer" },
example: 420,
},
{
$ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
},
],
responses: {
200: {
description: "Statistics overview",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/StatsResponse" },
example: {
total_sessions: 184,
active_sessions: 3,
active_agents: 5,
total_agents: 372,
total_events: 28451,
events_today: 612,
ws_connections: 2,
agents_by_status: {
working: 4,
waiting: 1,
completed: 360,
error: 7,
},
sessions_by_status: {
active: 3,
completed: 175,
error: 6,
},
},
},
},
},
},
},
},
"/api/analytics": {
get: {
tags: ["Analytics"],
summary: "Get analytics aggregates",
operationId: "getAnalytics",
description:
"Returns the full analytics rollup powering the Analytics screen: aggregate token " +
"usage (`tokens`), total estimated spend across all sessions (`total_cost`, in USD, " +
"computed from the configured pricing rules), per-tool invocation counts " +
"(`tool_usage`), per-day event and session time series (`daily_events`, " +
"`daily_sessions`), the distribution of subagent types (`agent_types`), per-type " +
"event counts (`event_types`), the mean number of events per session " +
"(`avg_events_per_session`), the total subagent count (`total_subagents`), and a " +
"nested `overview` object mirroring the headline session/agent/event counters.\n\n" +
"As with `/api/stats`, the top-level `agents_by_status` and `sessions_by_status` " +
"maps **omit statuses whose count is zero**. The `agent_types[].subagent_type` field " +
"may be `null` for the main agent / untyped subagents.\n\n" +
"The daily time series are bucketed by the caller's local day. Pass `tz_offset` as " +
"the minutes value from JS `Date.prototype.getTimezoneOffset()` (e.g. `420` for " +
"PDT). When omitted or non-numeric, the server buckets in UTC.",
parameters: [
{
in: "query",
name: "tz_offset",
description:
"Caller timezone offset in MINUTES, as returned by JS " +
"`Date.prototype.getTimezoneOffset()` (e.g. 420 for PDT, 300 for EDT, 0 for " +
"UTC). Used to bucket the `daily_events` / `daily_sessions` time series into " +
"the caller's local day. Defaults to UTC when omitted or non-numeric.",
schema: { type: "integer" },
example: 420,
},
{
$ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
},
],
responses: {
200: {
description: "Analytics response",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AnalyticsResponse" },
example: {
tokens: {
total_input: 4821002,
total_output: 1933517,
total_cache_read: 19288440,
total_cache_write: 2044120,
},
total_cost: 42.7183,
tool_usage: [
{ tool_name: "Bash", count: 5821 },
{ tool_name: "Read", count: 4310 },
{ tool_name: "Edit", count: 2980 },
{ tool_name: "Grep", count: 1744 },
],
daily_events: [
{ date: "2026-06-23", count: 488 },
{ date: "2026-06-24", count: 921 },
{ date: "2026-06-25", count: 612 },
],
daily_sessions: [
{ date: "2026-06-23", count: 4 },
{ date: "2026-06-24", count: 9 },
{ date: "2026-06-25", count: 6 },
],
agent_types: [
{ subagent_type: null, count: 184 },
{ subagent_type: "general-purpose", count: 96 },
{ subagent_type: "Explore", count: 71 },
{ subagent_type: "code-reviewer", count: 21 },
],
event_types: [
{ event_type: "PreToolUse", count: 14210 },
{ event_type: "PostToolUse", count: 13988 },
{ event_type: "Stop", count: 168 },
{ event_type: "SubagentStop", count: 85 },
],
avg_events_per_session: 154.6,
total_subagents: 188,
overview: {
total_sessions: 184,
active_sessions: 3,
active_agents: 5,
total_agents: 372,
total_events: 28451,
},
agents_by_status: {
working: 4,
waiting: 1,
completed: 360,
error: 7,
},
sessions_by_status: {
active: 3,
completed: 175,
error: 6,
},
},
},
},
},
},
},
},
"/api/hooks/event": {
post: {
tags: ["Hooks"],
summary: "Ingest Claude Code hook event",
operationId: "ingestHookEvent",
description:
"Primary ingestion endpoint for Claude Code lifecycle hooks. The hook handler posts " +
"an envelope of the form `{ hook_type, data }`, where `hook_type` is the Claude " +
"Code hook name (PreToolUse, PostToolUse, Stop, SubagentStop, Notification, " +
"SessionStart, SessionEnd) and `data` carries the raw hook payload — at minimum a " +
"`session_id`. The server upserts the session and its main agent on first sight, " +
"applies the appropriate lifecycle state transition, extracts token usage and " +
"compaction signals from the transcript when present, persists an `events` row " +
"(storing `data` as a JSON-encoded string), and broadcasts a `new_event` message " +
"over the WebSocket.\n\n" +
"On success the response is `{ ok: true, event: { ... } }`, where `event` echoes " +
"the normalized row that was just inserted (`session_id`, `agent_id`, `event_type`, " +
"`tool_name`, `summary`, `created_at`). Ingestion is designed to be fail-safe and " +
"non-blocking for the hook caller.\n\n" +
"Validation failures return HTTP 400 with an `ErrorResponse` body " +
"(`{ error: { code, message } }`): `INVALID_INPUT` when `hook_type` or `data` is " +
"missing, and `MISSING_SESSION` when `data.session_id` is absent.",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/HookEventRequest" },
example: {
hook_type: "PreToolUse",
data: {
session_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566",
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: {
command: "curl -s https://api.example.com/health",
description: "Check upstream health",
},
cwd: "/Users/dev/project",
transcript_path:
"/Users/dev/.claude/projects/-Users-dev-project/8f3c2a10-1b2c-4d5e-9f80-112233445566.jsonl",
},
},
},
},
},
responses: {
200: {
description: "Event processed",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/HookEventResponse" },
example: {
ok: true,
event: {
session_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566",
agent_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566-main",
event_type: "PreToolUse",
tool_name: "Bash",
summary: "Bash: curl -s https://api.example.com/health",
created_at: "2026-06-25T18:42:07.512Z",
},
},
},
},
},
400: {
description: "Invalid hook payload",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: {
code: "MISSING_SESSION",
message: "session_id is required in data",
},
},
},
},
},
},
},
},
};
module.exports = { tags, schemas, paths };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,835 @@
/**
* @file Enriched OpenAPI OVERRIDE operations for the Pricing and Alerts routes.
*
* These eight paths are ALREADY documented in `server/openapi.js`. This module
* re-declares the SAME operations (identical operationId / tags / request &
* response `$ref` schema names / parameters) but layers on richer prose
* descriptions plus realistic request/response/parameter examples so the
* generated Swagger UI is self-explanatory. The wire contract is unchanged
* no new schemas, no new tags. The base `$ref`s under
* `#/components/{schemas,parameters}` are reused verbatim.
*
* Shape: `{ tags: [], schemas: {}, paths: { ... } }`. The `tags` and `schemas`
* collections are intentionally empty; everything here is a path-level override.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
// ---------------------------------------------------------------------------
// Reusable realistic examples (kept here, NOT as components — examples live
// inline on the operations so the override carries no schema/component state).
// ---------------------------------------------------------------------------
/** A representative stored pricing rule (matches PricingRule schema fields). */
const PRICING_RULE_EXAMPLE = {
model_pattern: "claude-opus-4%",
display_name: "Claude Opus 4 (family)",
input_per_mtok: 15,
output_per_mtok: 75,
cache_read_per_mtok: 1.5,
cache_write_per_mtok: 18.75,
cache_write_1h_per_mtok: 30,
fast_input_per_mtok: 0,
fast_output_per_mtok: 0,
updated_at: "2026-06-25T18:42:11.000Z",
};
/** A second rule to make the list example look like a real catalog. */
const PRICING_RULE_EXAMPLE_2 = {
model_pattern: "claude-haiku%",
display_name: "Claude Haiku (family)",
input_per_mtok: 0.8,
output_per_mtok: 4,
cache_read_per_mtok: 0.08,
cache_write_per_mtok: 1,
cache_write_1h_per_mtok: 1.6,
fast_input_per_mtok: 0,
fast_output_per_mtok: 0,
updated_at: "2026-06-20T09:15:00.000Z",
};
/** A full CostResult-shaped example body returned by both cost endpoints. */
const COST_RESULT_EXAMPLE = {
total_cost: 12.8431,
breakdown: [
{
model: "claude-opus-4-8",
speed: "standard",
inference_geo: "global",
service_tier: "standard",
input_tokens: 184320,
output_tokens: 51200,
cache_read_tokens: 920000,
cache_write_tokens: 64000,
cache_write_1h_tokens: 12000,
web_search_requests: 8,
web_fetch_requests: 3,
code_execution_requests: 2,
cost: 8.4127,
matched_rule: "claude-opus-4%",
},
{
model: "claude-haiku-4-5",
speed: "fast",
inference_geo: "us",
service_tier: "standard",
input_tokens: 512000,
output_tokens: 128000,
cache_read_tokens: 64000,
cache_write_tokens: 8000,
cache_write_1h_tokens: 0,
web_search_requests: 0,
web_fetch_requests: 0,
code_execution_requests: 0,
cost: 1.5904,
matched_rule: "claude-haiku%",
},
],
feature_costs: {
web_search_cost: 0.08,
web_fetch_cost: 0,
code_execution_cost: 0,
code_execution_hours_estimated: 0.1667,
code_execution_free_hours: 50,
},
unpriced_models: [
{
model: "claude-experimental-preview",
input_tokens: 4096,
output_tokens: 2048,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
],
daily_costs: [
{ date: "2026-06-23", cost: 3.1102 },
{ date: "2026-06-24", cost: 5.7421 },
{ date: "2026-06-25", cost: 3.9908 },
],
};
/** A single fired-alert event row. `details` is a JSON STRING, per the route. */
const ALERT_EVENT_EXAMPLE = {
id: 42,
rule_id: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91",
rule_name: "Idle session watchdog",
rule_type: "inactivity",
message: "Session sess_8f2a has been inactive for 35 minutes",
details: '{"session_id":"sess_8f2a","minutes":35,"threshold":30}',
acknowledged: 0,
created_at: "2026-06-25T17:05:44.000Z",
};
/** A serialized alert RULE (config parsed to an object, enabled coerced bool). */
const ALERT_RULE_EXAMPLE = {
id: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91",
name: "Idle session watchdog",
rule_type: "inactivity",
config: { minutes: 30 },
enabled: true,
cooldown_seconds: 300,
created_at: "2026-06-10T12:00:00.000Z",
updated_at: "2026-06-24T08:30:00.000Z",
};
/** A second rule of a different type for the list example. */
const ALERT_RULE_EXAMPLE_2 = {
id: "1a2b3c4d-5e6f-7081-9201-aabbccddeeff",
name: "Heavy token burn",
rule_type: "token_threshold",
config: { total_tokens: 5000000 },
enabled: true,
cooldown_seconds: 600,
created_at: "2026-06-12T14:20:00.000Z",
updated_at: "2026-06-12T14:20:00.000Z",
};
module.exports = {
// No new tags — reuse the base "Pricing" and "Alerts" tags.
tags: [],
// No new schemas — every $ref below points at the base components.
schemas: {},
paths: {
// -----------------------------------------------------------------------
// PRICING
// -----------------------------------------------------------------------
"/api/pricing": {
get: {
tags: ["Pricing"],
summary: "List pricing rules",
operationId: "listPricingRules",
description:
"Returns every stored pricing rule, wrapped as `{ pricing: [ ... ] }`. " +
"Each rule carries per-MTok (per-million-token) rates for input, output, " +
"cache reads, and the two cache-write tiers (5-minute and 1-hour " +
"ephemeral), plus optional fast-mode input/output rates (0 = not " +
"configured). Rules are matched against model ids by treating the SQL " +
"`%` wildcard in `model_pattern` as `.*`; when several rules match, the " +
"longest (most specific) pattern wins. Rates here feed the cost " +
"calculations under `/api/pricing/cost`.",
responses: {
200: {
description: "Pricing rules",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PricingListResponse" },
example: { pricing: [PRICING_RULE_EXAMPLE, PRICING_RULE_EXAMPLE_2] },
},
},
},
},
},
put: {
tags: ["Pricing"],
summary: "Create/update pricing rule",
operationId: "upsertPricingRule",
description:
"Creates a pricing rule or updates the existing one with the same " +
"`model_pattern` (upsert keyed on `model_pattern`). `model_pattern` and " +
"`display_name` are required; every `*_per_mtok` rate is optional and " +
"defaults to 0 when omitted. Use the SQL `%` wildcard in `model_pattern` " +
"to match a model family (e.g. `claude-opus-4%`). Set " +
"`fast_input_per_mtok` / `fast_output_per_mtok` only if the model bills " +
"fast-mode usage at a premium; leave them 0 otherwise. " +
"Note the asymmetry with the list endpoint: the response wraps a SINGLE " +
"stored rule as `{ pricing: <rule> }` (not an array). A missing " +
"`model_pattern` or `display_name` returns 400 `INVALID_INPUT`, and so " +
"does any `*_per_mtok` rate that is not a non-negative finite number " +
"(numeric strings are coerced; NaN and negative rates are rejected " +
"before anything is written).",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PricingUpsertRequest" },
example: {
model_pattern: "claude-opus-4%",
display_name: "Claude Opus 4 (family)",
input_per_mtok: 15,
output_per_mtok: 75,
cache_read_per_mtok: 1.5,
cache_write_per_mtok: 18.75,
cache_write_1h_per_mtok: 30,
fast_input_per_mtok: 0,
fast_output_per_mtok: 0,
},
},
},
},
responses: {
200: {
description: "Pricing rule stored",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PricingUpsertResponse" },
example: { pricing: PRICING_RULE_EXAMPLE },
},
},
},
400: {
description: "Invalid request body",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: {
code: "INVALID_INPUT",
message: "model_pattern and display_name are required",
},
},
},
},
},
},
},
},
"/api/pricing/{pattern}": {
delete: {
tags: ["Pricing"],
summary: "Delete pricing rule",
operationId: "deletePricingRule",
description:
"Deletes the pricing rule whose `model_pattern` exactly matches the " +
"`pattern` path segment. The pattern is URL-ENCODED: the SQL `%` " +
"wildcard must be sent as `%25` (so `claude-opus-4%` becomes " +
"`claude-opus-4%25`). The server decodes it before lookup. Returns " +
"`{ ok: true }` on success, or 404 `NOT_FOUND` if no rule matches.",
parameters: [
// Mirrors components.parameters.PatternPath (name/in/required/schema
// identical), inlined so a realistic URL-encoded example can be
// attached — a bare $ref cannot carry an `example`.
{
name: "pattern",
in: "path",
required: true,
schema: { type: "string" },
description:
"Model pattern (URL-encoded). The SQL `%` wildcard must be escaped " +
"as `%25` (e.g. `claude-opus-4%25` for the rule `claude-opus-4%`).",
example: "claude-opus-4%25",
},
],
responses: {
200: {
description: "Rule deleted",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/DeleteOkResponse" },
example: { ok: true },
},
},
},
404: {
description: "Pricing rule not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: { code: "NOT_FOUND", message: "Pricing rule not found" },
},
},
},
},
},
},
},
"/api/pricing/cost": {
get: {
tags: ["Pricing"],
summary: "Get total token cost across all sessions",
operationId: "getTotalCost",
description:
"Computes the aggregate token cost across EVERY session by matching " +
"each (model, speed, inference_geo, service_tier) usage bucket against " +
"the most specific pricing rule. Returns `total_cost`, a per-bucket " +
"`breakdown`, `feature_costs` (web-search surcharge, code-execution " +
"container time with the org free-hours allowance applied), " +
"`unpriced_models` (usage with no matching rule, contributing $0 so the " +
"total stays honest), and `daily_costs` bucketed by local calendar day. " +
"Pass `tz_offset` (minutes; the JS `Date.getTimezoneOffset()` value, " +
"e.g. 300 for US Eastern, -120 for CEST) so day boundaries align with " +
"the viewer's timezone; omitted/invalid offsets fall back to UTC. " +
"Honors the `sources` data-scope filter, like the sessions / stats / " +
"analytics endpoints, so the reported cost matches the active scope.",
parameters: [
{
name: "tz_offset",
in: "query",
required: false,
schema: { type: "integer" },
description:
"Viewer timezone offset in minutes, as returned by " +
"`Date.getTimezoneOffset()` (positive for zones behind UTC, e.g. " +
"300 = US Eastern, -120 = CEST). Shifts the `daily_costs` day " +
"boundaries; invalid or omitted values default to UTC.",
example: 300,
},
{
name: "sources",
in: "query",
required: false,
schema: { type: "string" },
description:
"Comma-separated data-source ids to include (local history is " +
"`local`; remote SSH machines use their `remote_sources.id`). Omit " +
"for all sources. Narrows the aggregate cost to the given origins.",
example: "local",
},
],
responses: {
200: {
description: "Cost result",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CostResult" },
example: COST_RESULT_EXAMPLE,
},
},
},
},
},
},
"/api/pricing/cost/{sessionId}": {
get: {
tags: ["Pricing"],
summary: "Get token cost for one session",
operationId: "getSessionCost",
description:
"Same cost computation as `/api/pricing/cost`, but scoped to a single " +
"session's token usage. Returns the identical `CostResult` shape " +
"(`total_cost`, `breakdown`, `feature_costs`, `unpriced_models`, " +
"`daily_costs`); `daily_costs` holds at most one entry — the session's " +
"start date in the viewer's local day, or an empty array if the session " +
"id is unknown. Pass `tz_offset` (minutes, `Date.getTimezoneOffset()`) " +
"to place that start date in the viewer's timezone; defaults to UTC.",
parameters: [
{
name: "sessionId",
in: "path",
required: true,
schema: { type: "string" },
description: "Session ID to price.",
example: "sess_8f2a3b1c",
},
{
name: "tz_offset",
in: "query",
required: false,
schema: { type: "integer" },
description:
"Viewer timezone offset in minutes (`Date.getTimezoneOffset()`; " +
"300 = US Eastern, -120 = CEST). Places the session start date in " +
"the viewer's local day; defaults to UTC when omitted or invalid.",
example: 300,
},
],
responses: {
200: {
description: "Session cost result",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CostResult" },
example: {
...COST_RESULT_EXAMPLE,
total_cost: 8.4127,
daily_costs: [{ date: "2026-06-25", cost: 8.4127 }],
},
},
},
},
},
},
},
// -----------------------------------------------------------------------
// ALERTS
// -----------------------------------------------------------------------
"/api/alerts": {
get: {
tags: ["Alerts"],
summary: "List fired alerts, newest first",
operationId: "listAlerts",
description:
"Returns the fired-alert feed, newest first, as " +
"`{ alerts, total, unacked, limit, offset }`. Each alert event carries " +
"the originating rule's id/name/type, a human-readable `message`, an " +
"`acknowledged` flag (0/1), `created_at`, and `details` — which is a " +
"JSON STRING (not an object) that callers must `JSON.parse`. " +
"`limit` is clamped to 1200 (default 50) and negative `offset` is " +
"clamped to 0. Set `unacked=true` to return only unacknowledged alerts; " +
"`total` then counts only unacked rows, while `unacked` always reports " +
"the global unacknowledged count.",
parameters: [
{
name: "limit",
in: "query",
required: false,
schema: { type: "integer", minimum: 1, maximum: 200, default: 50 },
description:
"Page size, clamped to the 1200 range (default 50). Values " +
"outside the range are clamped, not rejected.",
example: 50,
},
{
name: "offset",
in: "query",
required: false,
schema: { type: "integer", minimum: 0 },
description: "Pagination offset; negative values are clamped to 0.",
example: 0,
},
{
name: "unacked",
in: "query",
required: false,
schema: { type: "boolean" },
description:
'When the literal string "true", return only unacknowledged ' +
"alerts (and scope `total` to that subset).",
example: "true",
},
],
responses: {
200: {
description: "Paginated alert feed with total and unacked counts",
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: true,
description: "Includes alerts[], total, unacked, limit, offset.",
},
example: {
alerts: [
ALERT_EVENT_EXAMPLE,
{
id: 41,
rule_id: "1a2b3c4d-5e6f-7081-9201-aabbccddeeff",
rule_name: "Heavy token burn",
rule_type: "token_threshold",
message: "Session sess_3c1d crossed 5,000,000 total tokens",
details:
'{"session_id":"sess_3c1d","total_tokens":5120000,"threshold":5000000}',
acknowledged: 1,
created_at: "2026-06-25T16:40:02.000Z",
},
],
total: 2,
unacked: 1,
limit: 50,
offset: 0,
},
},
},
},
},
},
},
"/api/alerts/rules": {
get: {
tags: ["Alerts"],
summary: "List alert rules",
operationId: "listAlertRules",
description:
"Returns all alert rules as `{ rules: [ ... ] }`. Each rule's `config` " +
"is returned as a PARSED object (the column is stored as JSON text), and " +
"`enabled` is coerced to a boolean. The `config` shape depends on " +
"`rule_type`: `event_pattern` uses event_type / tool_name / " +
"summary_contains plus optional count + window_minutes; `inactivity` " +
"uses `minutes`; `status_duration` uses `status` + `minutes`; " +
"`token_threshold` uses `total_tokens`.",
responses: {
200: {
description: "All alert rules with parsed config objects",
content: {
"application/json": {
schema: { type: "object", additionalProperties: true },
example: { rules: [ALERT_RULE_EXAMPLE, ALERT_RULE_EXAMPLE_2] },
},
},
},
},
},
post: {
tags: ["Alerts"],
summary: "Create an alert rule",
operationId: "createAlertRule",
description:
"Creates an alert rule and returns it serialized as `{ rule: { ... } }` " +
"with HTTP 201. `name`, `rule_type`, and `config` are required; the " +
"`config` shape is validated per `rule_type`:\n" +
"- `event_pattern`: `{ event_type?, tool_name?, summary_contains?, " +
"count?, window_minutes? }` — fires when matching events accumulate.\n" +
"- `inactivity`: `{ minutes }` — fires when a session goes idle.\n" +
"- `status_duration`: `{ status, minutes }` — fires when a session " +
"holds a status too long.\n" +
"- `token_threshold`: `{ total_tokens }` — fires when usage crosses a " +
"ceiling.\n" +
"`enabled` defaults to true and `cooldown_seconds` defaults to 300 " +
"(must be a non-negative integer). A bad name, unknown `rule_type`, " +
"invalid `config`, or negative cooldown returns 400 `INVALID_INPUT`.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name", "rule_type", "config"],
properties: {
name: { type: "string" },
rule_type: {
type: "string",
enum: ["event_pattern", "inactivity", "status_duration", "token_threshold"],
},
config: {
type: "object",
additionalProperties: true,
description:
"Type-specific config. event_pattern: event_type/tool_name/summary_contains + optional count/window_minutes. inactivity: minutes. status_duration: status + minutes. token_threshold: total_tokens.",
},
enabled: { type: "boolean", default: true },
cooldown_seconds: { type: "integer", default: 300 },
},
},
examples: {
inactivity: {
summary: "Inactivity rule",
value: {
name: "Idle session watchdog",
rule_type: "inactivity",
config: { minutes: 30 },
enabled: true,
cooldown_seconds: 300,
},
},
event_pattern: {
summary: "Event-pattern rule (repeated tool errors)",
value: {
name: "Repeated Bash failures",
rule_type: "event_pattern",
config: {
event_type: "PostToolUse",
tool_name: "Bash",
summary_contains: "error",
count: 3,
window_minutes: 10,
},
enabled: true,
cooldown_seconds: 600,
},
},
status_duration: {
summary: "Status-duration rule",
value: {
name: "Stuck waiting too long",
rule_type: "status_duration",
config: { status: "waiting", minutes: 15 },
enabled: true,
cooldown_seconds: 300,
},
},
token_threshold: {
summary: "Token-threshold rule",
value: {
name: "Heavy token burn",
rule_type: "token_threshold",
config: { total_tokens: 5000000 },
enabled: true,
cooldown_seconds: 600,
},
},
},
},
},
},
responses: {
201: {
description: "Created rule",
content: {
"application/json": {
schema: { type: "object", additionalProperties: true },
example: { rule: ALERT_RULE_EXAMPLE },
},
},
},
400: {
description: "Validation error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: { code: "INVALID_INPUT", message: "name is required" },
},
},
},
},
},
},
},
"/api/alerts/rules/{id}": {
patch: {
tags: ["Alerts"],
summary: "Update an alert rule (partial; rule_type is immutable)",
operationId: "updateAlertRule",
description:
"Partially updates an alert rule and returns it serialized as " +
"`{ rule: { ... } }`. Only the fields present in the body change; " +
"`rule_type` CANNOT be changed and any supplied `config` is validated " +
"against the rule's STORED type. `name` (if present) must be a " +
"non-empty string and `cooldown_seconds` (if present) must be a " +
"non-negative integer. Returns 404 `NOT_FOUND` for an unknown id, or " +
"400 `INVALID_INPUT` for a bad name, invalid config, or negative " +
"cooldown.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Alert rule ID (UUID).",
example: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91",
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
name: { type: "string" },
config: { type: "object", additionalProperties: true },
enabled: { type: "boolean" },
cooldown_seconds: { type: "integer" },
},
},
examples: {
disableRule: {
summary: "Disable a rule without touching its config",
value: { enabled: false },
},
retuneInactivity: {
summary: "Re-tune an inactivity rule's threshold + cooldown",
value: { config: { minutes: 45 }, cooldown_seconds: 900 },
},
rename: {
summary: "Rename a rule",
value: { name: "Idle session watchdog (prod)" },
},
},
},
},
},
responses: {
200: {
description: "Updated rule",
content: {
"application/json": {
schema: { type: "object", additionalProperties: true },
example: {
rule: { ...ALERT_RULE_EXAMPLE, enabled: false, cooldown_seconds: 900 },
},
},
},
},
400: {
description: "Validation error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: {
code: "INVALID_INPUT",
message: "cooldown_seconds must be a non-negative integer",
},
},
},
},
},
404: {
description: "Rule not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: { code: "NOT_FOUND", message: "Alert rule not found" },
},
},
},
},
},
},
delete: {
tags: ["Alerts"],
summary: "Delete an alert rule and its fired-alert history",
operationId: "deleteAlertRule",
description:
"Deletes the alert rule with the given id. Its fired-alert history " +
"cascades away with it (the foreign key is ON DELETE CASCADE), so any " +
"alerts previously raised by this rule are also removed from the feed. " +
"Returns `{ ok: true }` on success or 404 `NOT_FOUND` for an unknown id.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Alert rule ID (UUID).",
example: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91",
},
],
responses: {
200: {
description: "Deletion confirmation",
content: {
"application/json": {
schema: { type: "object", additionalProperties: true },
example: { ok: true },
},
},
},
404: {
description: "Rule not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: { code: "NOT_FOUND", message: "Alert rule not found" },
},
},
},
},
},
},
},
"/api/alerts/{id}/ack": {
post: {
tags: ["Alerts"],
summary: "Acknowledge one fired alert",
operationId: "ackAlert",
description:
"Marks a single fired alert (by its integer event id) as acknowledged " +
"and returns the updated row as `{ alert: { ... } }` (with " +
"`acknowledged: 1`). Acknowledging also broadcasts an `alert_updated` " +
"WebSocket message so connected dashboards refresh their unacked badge. " +
"The id must be numeric; an unknown id returns 404 `NOT_FOUND`.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "integer" },
description: "Alert event ID (numeric).",
example: 42,
},
],
responses: {
200: {
description: "Acknowledged alert row",
content: {
"application/json": {
schema: { type: "object", additionalProperties: true },
example: { alert: { ...ALERT_EVENT_EXAMPLE, acknowledged: 1 } },
},
},
},
404: {
description: "Alert not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: { code: "NOT_FOUND", message: "Alert not found" },
},
},
},
},
},
},
},
"/api/alerts/ack-all": {
post: {
tags: ["Alerts"],
summary: "Acknowledge all unacked alerts",
operationId: "ackAllAlerts",
description:
"Acknowledges every currently unacknowledged alert in one call and " +
"returns `{ ok: true, acknowledged: <count> }` where `acknowledged` is " +
"the number of rows actually updated. When at least one alert is " +
"acknowledged, an `alert_updated` WebSocket message (`{ acked_all: " +
"true }`) is broadcast so dashboards clear their unacked badge. Calling " +
"this when nothing is unacked returns `acknowledged: 0`.",
responses: {
200: {
description: "Count of acknowledged alerts",
content: {
"application/json": {
schema: { type: "object", additionalProperties: true },
example: { ok: true, acknowledged: 3 },
},
},
},
},
},
},
},
};
@@ -0,0 +1,757 @@
/**
* @file Enriched OVERRIDE fragments for the already-documented Sessions and
* Agents endpoints. These paths exist in the base spec (server/openapi.js); the
* loader (server/openapi-extra.js) merges `paths` with override-on-key
* semantics, so the operations below REPLACE the terser base versions while
* preserving their contract: same `operationId`, same `tags`, and the same
* request/response `$ref` schema names. The only additions are richer
* `description`s and realistic `example`s on every parameter, response media
* type, and request body purely documentation, no contract change.
*
* No new schemas are defined here (`schemas` is empty by design); everything
* reuses the base `components.schemas` and `components.parameters`. Error
* responses keep referencing the base `ErrorResponse` ({ error: { code,
* message } }). The Sessions/Agents tags are already declared in the base
* literal, so `tags` is intentionally empty.
*
* Covers:
* - GET /api/sessions (listSessions)
* - POST /api/sessions (createSession)
* - GET /api/sessions/{id} (getSession)
* - PATCH /api/sessions/{id} (updateSession)
* - GET /api/sessions/{id}/stats (getSessionStats)
* - GET /api/sessions/{id}/transcripts (listSessionTranscripts)
* - GET /api/sessions/{id}/transcript (getSessionTranscript)
* - GET /api/agents (listAgents)
* - POST /api/agents (createAgent)
* - GET /api/agents/{id} (getAgent)
* - PATCH /api/agents/{id} (updateAgent)
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const tags = [];
const schemas = {};
// --- Reusable realistic example fixtures ----------------------------------
// Keep these consistent with the route handlers in server/routes/sessions.js
// and server/routes/agents.js. Timestamps are ISO-8601 UTC with millisecond
// precision; metadata is a raw JSON-encoded string (the DB column is TEXT).
const exampleSession = {
id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
name: "Refactor pricing route + add cost endpoint",
status: "active",
cwd: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
model: "claude-opus-4-20250514",
started_at: "2026-06-25T14:02:11.004Z",
ended_at: null,
metadata: '{"source":"hook","git_branch":"feat/spend-budgets"}',
updated_at: "2026-06-25T14:31:50.119Z",
agent_count: 4,
last_activity: "2026-06-25T14:31:50.119Z",
cost: 0.8421,
awaiting_input_since: null,
awaiting_reason: null,
};
const exampleCompletedSession = {
id: "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
name: "Fix flaky transcript pagination test",
status: "completed",
cwd: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
model: "claude-sonnet-4-20250514",
started_at: "2026-06-24T09:12:00.000Z",
ended_at: "2026-06-24T09:48:32.501Z",
metadata: null,
updated_at: "2026-06-24T09:48:32.501Z",
agent_count: 1,
last_activity: "2026-06-24T09:48:32.501Z",
cost: 0.1532,
awaiting_input_since: null,
awaiting_reason: null,
};
const exampleMainAgent = {
id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
name: "Main Agent",
type: "main",
subagent_type: null,
status: "working",
task: null,
current_tool: "Edit",
started_at: "2026-06-25T14:02:11.004Z",
ended_at: null,
parent_agent_id: null,
metadata: '{"model":"claude-opus-4-20250514"}',
updated_at: "2026-06-25T14:31:50.119Z",
awaiting_input_since: null,
awaiting_reason: null,
};
const exampleSubagent = {
id: "ad18a79192af10ed1",
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
name: "Explore pricing module",
type: "subagent",
subagent_type: "Explore",
status: "completed",
task: "Map every caller of calculateCost() across server/routes",
current_tool: null,
started_at: "2026-06-25T14:10:22.310Z",
ended_at: "2026-06-25T14:14:09.882Z",
parent_agent_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
metadata: null,
updated_at: "2026-06-25T14:14:09.882Z",
awaiting_input_since: null,
awaiting_reason: null,
};
const exampleEvent = {
id: 48213,
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
agent_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
event_type: "PostToolUse",
tool_name: "Edit",
summary: "Edited server/routes/pricing.js",
data: '{"tool_input":{"file_path":"server/routes/pricing.js"},"tool_response":{"success":true}}',
created_at: "2026-06-25T14:31:50.119Z",
};
const paths = {
"/api/sessions": {
get: {
tags: ["Sessions"],
summary: "List sessions",
description:
"Returns a paginated list of sessions, newest activity first, each enriched with a SQL `agent_count` (LEFT JOIN onto agents), a `last_activity` alias of `updated_at`, and a `cost` computed from the session's token usage against the current pricing rules. The `status` and `q` filters compose (AND) with each other and with pagination; `q` is a case-insensitive LIKE across `id`, `name`, and `cwd`. `total` reflects all rows matching the filters independent of `limit`/`offset` so paginators stay accurate, while `cost` is only calculated for the rows on the returned page (when `sort_by=price` it is computed across all matching rows so the price sort is correct). The endpoint is read-only with no side effects; `metadata` on each session is returned as a raw JSON-encoded string, not a parsed object.",
operationId: "listSessions",
parameters: [
{ $ref: "#/components/parameters/SessionStatusQuery", example: "active" },
{
name: "q",
in: "query",
schema: { type: "string" },
description:
"Case-insensitive search across `id` / `name` / `cwd`. Composes with the status filter when both are present.",
example: "pricing",
},
{
$ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
},
{ $ref: "#/components/parameters/LimitQuery", example: 50 },
{ $ref: "#/components/parameters/OffsetQuery", example: 0 },
],
responses: {
200: {
description: "Session list",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionsListResponse" },
example: {
sessions: [exampleSession, exampleCompletedSession],
limit: 50,
offset: 0,
total: 137,
},
},
},
},
},
},
post: {
tags: ["Sessions"],
summary: "Create session (idempotent)",
description:
'Creates a session keyed by `id`. The operation is idempotent: if a session with that `id` already exists it is returned untouched with `created: false` and HTTP 200; only a brand-new row yields `created: true` and HTTP 201. New sessions are inserted with `status: "active"` and any omitted optional fields stored as null. The `metadata` field is accepted as a JSON object in the request but persisted (and returned on the session) as a JSON-encoded string. A successful create broadcasts a `session_created` websocket frame. A missing `id` returns 400 with code `INVALID_INPUT`.',
operationId: "createSession",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionCreateRequest" },
example: {
id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
name: "Refactor pricing route + add cost endpoint",
cwd: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
model: "claude-opus-4-20250514",
metadata: { source: "hook", git_branch: "feat/spend-budgets" },
},
},
},
},
responses: {
201: {
description: "Session created",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionCreateResponse" },
example: {
session: {
...exampleSession,
agent_count: 0,
cost: 0,
last_activity: "2026-06-25T14:02:11.004Z",
updated_at: "2026-06-25T14:02:11.004Z",
},
created: true,
},
},
},
},
200: {
description: "Session already exists",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionCreateResponse" },
example: {
session: exampleSession,
created: false,
},
},
},
},
400: {
description: "Invalid request body",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code: "INVALID_INPUT", message: "id is required" } },
},
},
},
},
},
},
"/api/sessions/{id}": {
get: {
tags: ["Sessions"],
summary: "Get session details",
description:
"Returns a single session together with all of its agents (chronological) and persisted events. Read-only, no side effects. The session's `metadata` and each event's `data` are returned as raw JSON-encoded strings, not parsed objects. Returns 404 with code `NOT_FOUND` when no session matches the path `id`.",
operationId: "getSession",
parameters: [
{
$ref: "#/components/parameters/SessionIdPath",
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
},
],
responses: {
200: {
description: "Session with associated agents/events",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionDetailResponse" },
example: {
session: exampleSession,
agents: [exampleMainAgent, exampleSubagent],
events: [exampleEvent],
},
},
},
},
404: {
description: "Session not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
},
},
},
},
},
patch: {
tags: ["Sessions"],
summary: "Update session",
description:
"Partially updates a session by `id`. Only `name`, `status`, `ended_at`, and `metadata` are accepted; any field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.",
operationId: "updateSession",
parameters: [
{
$ref: "#/components/parameters/SessionIdPath",
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionUpdateRequest" },
example: {
status: "completed",
ended_at: "2026-06-25T15:07:44.220Z",
metadata: { source: "hook", git_branch: "feat/spend-budgets", outcome: "merged" },
},
},
},
},
responses: {
200: {
description: "Session updated",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionUpdateResponse" },
example: {
session: {
...exampleSession,
status: "completed",
ended_at: "2026-06-25T15:07:44.220Z",
metadata:
'{"source":"hook","git_branch":"feat/spend-budgets","outcome":"merged"}',
updated_at: "2026-06-25T15:07:44.220Z",
last_activity: "2026-06-25T15:07:44.220Z",
},
},
},
},
},
404: {
description: "Session not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
},
},
},
},
},
},
"/api/sessions/{id}/stats": {
get: {
tags: ["Sessions"],
summary: "Get aggregated session stats",
description:
"Returns aggregated counts for the SessionOverview panel: total events, events-by-type, the top 15 tools by usage, an error count (events whose `event_type`/`summary` match /error/i or /failed/i), the event time range, agent type/status counts, the subagent-type breakdown (excluding the special `compaction` type, which is surfaced under `agents.compaction`), and token totals. All aggregation runs in SQL, so it stays cheap even for sessions with tens of thousands of events; the endpoint is read-only with no side effects. The frontend debounces calls on `new_event` / `agent_*` / `session_updated` websocket frames so the counters track a running session. Returns 404 with code `NOT_FOUND` when the session does not exist.",
operationId: "getSessionStats",
parameters: [
{
$ref: "#/components/parameters/SessionIdPath",
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
},
],
responses: {
200: {
description: "Aggregated session stats",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionStatsResponse" },
example: {
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
total_events: 1284,
events_by_type: [
{ event_type: "PostToolUse", count: 612 },
{ event_type: "PreToolUse", count: 612 },
{ event_type: "Notification", count: 41 },
{ event_type: "Stop", count: 19 },
],
tools_used: [
{ tool_name: "Bash", count: 188 },
{ tool_name: "Edit", count: 143 },
{ tool_name: "Read", count: 121 },
{ tool_name: "Grep", count: 77 },
],
error_count: 6,
first_event_at: "2026-06-25T14:02:11.052Z",
last_event_at: "2026-06-25T14:31:50.119Z",
agents: {
total: 4,
main: 1,
subagent: 3,
compaction: 1,
by_status: { working: 1, completed: 2, error: 1 },
},
subagent_types: [
{ subagent_type: "Explore", count: 2 },
{ subagent_type: "general-purpose", count: 1 },
],
tokens: {
input_tokens: 18422,
output_tokens: 9134,
cache_read_tokens: 1204880,
cache_write_tokens: 88210,
},
},
},
},
},
404: {
description: "Session not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
},
},
},
},
},
},
"/api/sessions/{id}/transcripts": {
get: {
tags: ["Sessions"],
summary: "List available transcripts for a session",
description:
"Lists every JSONL transcript file associated with a session — the main agent's transcript plus any subagent and compaction transcripts — by scanning the on-disk Claude project directory (live files, falling back to import-time snapshots). Read-only, no side effects. Each entry carries a best-effort `db_agent_id` resolved by matching transcripts to tracked agents (exact id first, then positional-by-time within each type group); it may be null when a transcript has no matching agent row. Used by the Conversation tab to populate the transcript switcher. Returns 404 with code `NOT_FOUND` when the session does not exist.",
operationId: "listSessionTranscripts",
parameters: [
{
$ref: "#/components/parameters/SessionIdPath",
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
},
],
responses: {
200: {
description: "List of transcripts available for the session",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/TranscriptListResponse" },
example: {
transcripts: [
{
id: "main",
name: "Main Agent",
type: "main",
has_transcript: true,
db_agent_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
},
{
id: "ad18a79192af10ed1",
name: "Explore pricing module",
type: "subagent",
subagent_type: "Explore",
has_transcript: true,
db_agent_id: "ad18a79192af10ed1",
},
{
id: "acompact-7c1e2f90",
name: "Context Compaction",
type: "compaction",
subagent_type: null,
has_transcript: true,
db_agent_id: null,
},
],
},
},
},
},
404: {
description: "Session not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
},
},
},
},
},
},
"/api/sessions/{id}/transcript": {
get: {
tags: ["Sessions"],
summary: "Stream messages from a specific transcript",
description:
"Returns parsed, renderable messages from a JSONL transcript with cursor-based pagination, reading the live file under ~/.claude/projects and falling back to the durable import-time snapshot. Pass `agent_id` to select a specific subagent or compaction transcript (default is the session's main transcript). Pagination cursors are mutually exclusive: `after` returns messages strictly newer than a JSONL line number (incremental live updates on `new_event`), `before` returns messages strictly older than a line (load-on-scroll-up), and `offset` is legacy start-offset paging. `last_line`/`first_line` are the JSONL line numbers of the newest/oldest returned message — feed them back as `after`/`before`. When the session, transcript file, or path cannot be found the endpoint degrades gracefully to an empty result (`messages: []`, `total: 0`, `has_more: false`) rather than erroring. Read-only, no side effects.",
operationId: "getSessionTranscript",
parameters: [
{
$ref: "#/components/parameters/SessionIdPath",
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
},
{
name: "agent_id",
in: "query",
schema: { type: "string" },
description:
"Transcript identifier — 'main' for the session's main transcript, or a subagent / compaction id from /transcripts.",
example: "main",
},
{
name: "limit",
in: "query",
schema: { type: "integer", default: 50, minimum: 1, maximum: 500 },
description: "Maximum number of messages to return.",
example: 50,
},
{
name: "offset",
in: "query",
schema: { type: "integer", minimum: 0 },
description:
"Offset from the start of the transcript (mutually exclusive with after/before).",
example: 0,
},
{
name: "after",
in: "query",
schema: { type: "integer", minimum: 0 },
description:
"Only return messages whose JSONL line number is strictly greater than this value. Used for incremental live updates.",
example: 842,
},
{
name: "before",
in: "query",
schema: { type: "integer", minimum: 0 },
description:
"Only return messages whose JSONL line number is strictly less than this value. Used to load older messages on scroll-up.",
example: 200,
},
],
responses: {
200: {
description: "Parsed messages with cursor metadata",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/TranscriptResponse" },
example: {
messages: [
{
type: "user",
timestamp: "2026-06-25T14:02:11.004Z",
content: [
{ type: "text", text: "Refactor the pricing route and add a cost endpoint." },
],
},
{
type: "assistant",
timestamp: "2026-06-25T14:02:18.771Z",
model: "claude-opus-4-20250514",
content: [
{ type: "thinking", text: "I'll start by reading server/routes/pricing.js." },
{
type: "tool_use",
name: "Read",
id: "toolu_01A7c2Df9",
input: { file_path: "server/routes/pricing.js" },
},
],
usage: { input_tokens: 412, output_tokens: 96 },
},
{
type: "user",
timestamp: "2026-06-25T14:02:19.330Z",
content: [
{
type: "tool_result",
id: "toolu_01A7c2Df9",
output: 'const { Router } = require("express");\n...',
is_error: false,
},
],
},
],
total: 1284,
has_more: true,
last_line: 5310,
first_line: 5301,
},
},
},
},
404: {
description: "Session or transcript not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
},
},
},
},
},
},
"/api/agents": {
get: {
tags: ["Agents"],
summary: "List agents",
description:
"Returns agents, most recent first. Filters are applied with precedence rather than composition: when `session_id` is supplied it wins and returns every agent for that session (ignoring `status` and pagination); otherwise a `status` filter returns paginated agents in that lifecycle state; otherwise all agents are returned paginated. `limit` defaults to 10000 when not a positive integer. Read-only, no side effects. Each agent's `metadata` is returned as a raw JSON-encoded string, not a parsed object.",
operationId: "listAgents",
parameters: [
{ $ref: "#/components/parameters/AgentStatusQuery", example: "working" },
{
$ref: "#/components/parameters/SessionFilterQuery",
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
},
{
$ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
},
{ $ref: "#/components/parameters/LimitQuery", example: 50 },
{ $ref: "#/components/parameters/OffsetQuery", example: 0 },
],
responses: {
200: {
description: "Agent list",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentsListResponse" },
example: {
agents: [exampleMainAgent, exampleSubagent],
limit: 50,
offset: 0,
},
},
},
},
},
},
post: {
tags: ["Agents"],
summary: "Create agent (idempotent)",
description:
'Creates an agent keyed by `id`. The operation is idempotent: if an agent with that `id` already exists it is returned untouched with `created: false` and HTTP 200; only a brand-new row yields `created: true` and HTTP 201. Omitted optional fields default server-side — `type` to `"main"`, `status` to `"waiting"` — and other unspecified columns are stored as null. `metadata` is accepted as a JSON object but persisted (and returned) as a JSON-encoded string. A successful create broadcasts an `agent_created` websocket frame. Missing `id`, `session_id`, or `name` returns 400 with code `INVALID_INPUT`.',
operationId: "createAgent",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentCreateRequest" },
example: {
id: "ad18a79192af10ed1",
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
name: "Explore pricing module",
type: "subagent",
subagent_type: "Explore",
status: "working",
task: "Map every caller of calculateCost() across server/routes",
parent_agent_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
metadata: { spawned_by: "Task" },
},
},
},
},
responses: {
201: {
description: "Agent created",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentCreateResponse" },
example: {
agent: {
...exampleSubagent,
status: "working",
ended_at: null,
metadata: '{"spawned_by":"Task"}',
updated_at: "2026-06-25T14:10:22.310Z",
},
created: true,
},
},
},
},
200: {
description: "Agent already exists",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentCreateResponse" },
example: {
agent: exampleSubagent,
created: false,
},
},
},
},
400: {
description: "Invalid request body",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: { code: "INVALID_INPUT", message: "id, session_id, and name are required" },
},
},
},
},
},
},
},
"/api/agents/{id}": {
get: {
tags: ["Agents"],
summary: "Get agent",
description:
"Returns a single agent by `id`. Read-only, no side effects. The agent's `metadata` is returned as a raw JSON-encoded string, not a parsed object. Returns 404 with code `NOT_FOUND` when no agent matches the path `id`.",
operationId: "getAgent",
parameters: [{ $ref: "#/components/parameters/AgentIdPath", example: "ad18a79192af10ed1" }],
responses: {
200: {
description: "Agent details",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentDetailResponse" },
example: { agent: exampleSubagent },
},
},
},
404: {
description: "Agent not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code: "NOT_FOUND", message: "Agent not found" } },
},
},
},
},
},
patch: {
tags: ["Agents"],
summary: "Update agent",
description:
"Partially updates an agent by `id`. Accepts `name`, `status`, `task`, `current_tool`, `ended_at`, and `metadata`. The UPDATE uses COALESCE, so any field omitted (passed as null) leaves the existing column value unchanged — with one deliberate exception: `current_tool` is written through verbatim when present in the body, so it can be explicitly cleared to null (e.g. when a tool call finishes). `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts an `agent_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the agent does not exist.",
operationId: "updateAgent",
parameters: [{ $ref: "#/components/parameters/AgentIdPath", example: "ad18a79192af10ed1" }],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentUpdateRequest" },
example: {
status: "completed",
current_tool: null,
ended_at: "2026-06-25T14:14:09.882Z",
},
},
},
},
responses: {
200: {
description: "Agent updated",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentUpdateResponse" },
example: {
agent: {
...exampleSubagent,
status: "completed",
current_tool: null,
ended_at: "2026-06-25T14:14:09.882Z",
updated_at: "2026-06-25T14:14:09.882Z",
},
},
},
},
},
404: {
description: "Agent not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code: "NOT_FOUND", message: "Agent not found" } },
},
},
},
},
},
},
};
module.exports = { tags, schemas, paths };
+338
View File
@@ -0,0 +1,338 @@
/**
* @file Supplementary OpenAPI 3.0 fragments for the Web Push routes mounted at
* `/api/push` (see server/routes/push.js + server/lib/push.js). Exports
* `{ tags, schemas, paths }` for merging into the base spec by
* `createOpenApiSpec()` via server/openapi-extra.js. Schemas are prefixed
* `Push` to avoid collisions with the base component schemas. Error responses
* reuse the base `MessageErrorResponse` schema (`{ error: { message } }`),
* which is the short shape these routes actually emit.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Push",
description:
"Web Push notification subscriptions and broadcast (VAPID); also fires native Electron notifications when hosted in the desktop app",
},
];
const schemas = {
PushVapidKeyResponse: {
type: "object",
required: ["publicKey"],
description:
"The server's VAPID public key. The browser passes this base64url-encoded key to `PushManager.subscribe({ applicationServerKey })` so the push service will accept deliveries signed by this server's private key.",
properties: {
publicKey: {
type: "string",
description:
"Base64url-encoded VAPID (P-256 ECDSA) public application server key. Generated once and persisted alongside the SQLite DB so the web app and native apps share one key pair.",
example:
"BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8",
},
},
},
PushSubscriptionKeys: {
type: "object",
required: ["p256dh", "auth"],
description:
"Client encryption keys produced by the browser's PushManager subscription. Both are required to encrypt Web Push payloads for the endpoint.",
properties: {
p256dh: {
type: "string",
description:
"Base64url-encoded P-256 ECDH public key from the browser subscription (`subscription.getKey('p256dh')`). Stored verbatim in the `push_subscriptions` table.",
example:
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
},
auth: {
type: "string",
description:
"Base64url-encoded auth secret from the browser subscription (`subscription.getKey('auth')`). Stored verbatim in the `push_subscriptions` table.",
example: "tBHItJI5svbpez7KI4CCXg",
},
},
},
PushSubscribeRequest: {
type: "object",
required: ["endpoint", "keys"],
description:
"A browser PushSubscription serialized for storage. Persisted via `INSERT OR REPLACE` keyed on `endpoint`, so re-subscribing the same endpoint is idempotent (it overwrites the stored keys rather than duplicating the row).",
properties: {
endpoint: {
type: "string",
format: "uri",
description:
"The push service delivery URL from `subscription.endpoint`. Acts as the primary key in `push_subscriptions`; sending later POSTs encrypted payloads here. Subscriptions that return HTTP 410 (Gone) during `/send` are pruned automatically.",
example: "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLWZha2UtZW5kcG9pbnQ",
},
keys: { $ref: "#/components/schemas/PushSubscriptionKeys" },
},
},
PushSubscribeResponse: {
type: "object",
required: ["ok"],
description: "Confirmation that the subscription was stored (or overwritten).",
properties: {
ok: {
type: "boolean",
enum: [true],
description: "Always `true` on success.",
example: true,
},
},
},
PushUnsubscribeRequest: {
type: "object",
required: ["endpoint"],
description:
"Identifies the subscription to delete by its push-service endpoint. NOTE: the endpoint is supplied in the request BODY (DELETE with a JSON body), not as a query parameter.",
properties: {
endpoint: {
type: "string",
format: "uri",
description:
"The `endpoint` of the subscription to remove from `push_subscriptions`. Deletion is idempotent — removing an endpoint that is not stored still returns `{ ok: true }`.",
example: "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLWZha2UtZW5kcG9pbnQ",
},
},
},
PushOkResponse: {
type: "object",
required: ["ok"],
description: "Generic success acknowledgement returned by subscribe/unsubscribe.",
properties: {
ok: {
type: "boolean",
enum: [true],
description: "Always `true` on success.",
example: true,
},
},
},
PushSendRequest: {
type: "object",
required: ["title", "body"],
description:
"Notification content to broadcast. Both fields are mandatory; a missing title or body yields a 400. The same title/body is delivered to every reachable surface (native Electron notification + all stored Web Push subscriptions).",
properties: {
title: {
type: "string",
description: "Notification title line.",
example: "Session completed",
},
body: {
type: "string",
description: "Notification body text.",
example: "Your Claude Code session finished with 3 subagents.",
},
},
},
PushSendResponse: {
type: "object",
required: ["ok", "native", "pushed", "failed"],
description:
"Reports which delivery surfaces actually fired. This lets the client distinguish a real delivery from a silent no-op (no subscribers AND no Electron host), which would otherwise look like success.",
properties: {
ok: {
type: "boolean",
enum: [true],
description: "Always `true` when dispatch ran without throwing.",
example: true,
},
native: {
type: "boolean",
description:
"`true` when a native OS notification was shown via Electron's main-process Notification API (i.e. the server is hosted inside the desktop app and notifications are supported). `false` under a plain `npm start` host.",
example: false,
},
pushed: {
type: "integer",
minimum: 0,
description:
"Count of stored Web Push subscriptions that accepted the encrypted payload (fulfilled `web-push` sends).",
example: 2,
},
failed: {
type: "integer",
minimum: 0,
description:
"Count of Web Push sends that were rejected. Subscriptions rejected with HTTP 410 (Gone) are deleted from `push_subscriptions` as part of this request.",
example: 1,
},
},
},
};
const paths = {
"/api/push/vapid-public-key": {
get: {
tags: ["Push"],
summary: "Get the VAPID public key",
description:
"Returns the server's VAPID public application server key so a browser can register a Web Push subscription via `PushManager.subscribe({ applicationServerKey })`. The key pair is generated once and persisted in the shared data directory alongside the SQLite DB, so the web app and native apps reuse a single key pair across restarts. No authentication — this is a local-first dashboard. Safe to call repeatedly; always returns the same key.",
operationId: "pushVapidPublicKey",
responses: {
200: {
description: "The VAPID public key",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PushVapidKeyResponse" },
example: {
publicKey:
"BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8",
},
},
},
},
},
},
},
"/api/push/subscribe": {
post: {
tags: ["Push"],
summary: "Register a Web Push subscription",
description:
"Stores a browser PushSubscription so future `/api/push/send` broadcasts reach this endpoint. Persisted with `INSERT OR REPLACE INTO push_subscriptions (endpoint, p256dh, auth)`, keyed on `endpoint` — so the operation is idempotent: re-subscribing the same endpoint overwrites its keys instead of creating a duplicate. No authentication (local-first). Requires `endpoint`, `keys.p256dh`, and `keys.auth`; any missing field returns 400.",
operationId: "pushSubscribe",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PushSubscribeRequest" },
example: {
endpoint: "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLWZha2UtZW5kcG9pbnQ",
keys: {
p256dh:
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
auth: "tBHItJI5svbpez7KI4CCXg",
},
},
},
},
},
responses: {
200: {
description: "Subscription stored (created or overwritten)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PushSubscribeResponse" },
example: { ok: true },
},
},
},
400: {
description:
"Missing required fields (one of `endpoint`, `keys.p256dh`, `keys.auth` was absent)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/MessageErrorResponse" },
example: { error: { message: "Missing required fields" } },
},
},
},
},
},
delete: {
tags: ["Push"],
summary: "Remove a Web Push subscription",
description:
"Deletes a stored subscription so it stops receiving broadcasts. The endpoint identifier is supplied in the request BODY (a DELETE with a JSON body), NOT as a query parameter. Idempotent — deleting an endpoint that is not stored still returns `{ ok: true }`. No authentication (local-first). A missing `endpoint` returns 400.",
operationId: "pushUnsubscribe",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PushUnsubscribeRequest" },
example: {
endpoint: "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLWZha2UtZW5kcG9pbnQ",
},
},
},
},
responses: {
200: {
description: "Subscription removed (or no-op if it was not stored)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PushOkResponse" },
example: { ok: true },
},
},
},
400: {
description: "Missing endpoint in the request body",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/MessageErrorResponse" },
example: { error: { message: "Missing endpoint" } },
},
},
},
},
},
},
"/api/push/send": {
post: {
tags: ["Push"],
summary: "Broadcast a notification to all surfaces",
description:
"Dispatches a notification to every reachable surface at once: it fires a native OS notification via Electron's main-process Notification API when the server is hosted inside the desktop app, AND sends an encrypted Web Push delivery to every stored subscription. Both legs run unconditionally so whichever surface the user is on receives the alert — under `npm start` the native leg is a no-op, and under the desktop app the Web Push leg is typically a no-op (Electron has no FCM credentials, so `push_subscriptions` is empty). Subscriptions rejected with HTTP 410 (Gone) are pruned from `push_subscriptions` during the request. The response reports `{ native, pushed, failed }` so the caller can tell a real delivery from a silent no-op. No authentication (local-first). A missing `title` or `body` returns 400; an unexpected dispatch error returns 500.",
operationId: "pushSend",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PushSendRequest" },
example: {
title: "Session completed",
body: "Your Claude Code session finished with 3 subagents.",
},
},
},
},
responses: {
200: {
description:
"Dispatch ran; the body reports which surfaces fired and how many Web Push deliveries succeeded/failed",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/PushSendResponse" },
example: { ok: true, native: false, pushed: 2, failed: 1 },
},
},
},
400: {
description: "Missing title or body",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/MessageErrorResponse" },
example: { error: { message: "Missing title or body" } },
},
},
},
500: {
description: "Dispatch error while broadcasting the notification",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/MessageErrorResponse" },
example: { error: { message: "Push service unavailable" } },
},
},
},
},
},
},
};
module.exports = { tags, schemas, paths };
File diff suppressed because it is too large Load Diff
+2930
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
/**
* @file Express router for managing agents, providing endpoints to list, retrieve, create, and update agents. It interacts with the database using prepared statements and broadcasts changes to connected WebSocket clients for real-time updates.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { stmts, db } = require("../db");
const { broadcast } = require("../websocket");
const { attachAgentCosts } = require("./pricing");
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
const router = Router();
router.get("/", (req, res) => {
const rawLimit = parseInt(req.query.limit);
const limit = rawLimit > 0 ? rawLimit : 10000;
const offset = parseInt(req.query.offset) || 0;
const status = req.query.status;
const session_id = req.query.session_id;
const sources = parseSources(req);
let rows;
if (session_id) {
// A session belongs to exactly one source, so no extra source filter needed.
rows = stmts.listAgentsBySession.all(session_id);
} else if (sources) {
// Data-scope active: build a dynamic query restricting agents to sessions
// from the chosen machines. `agents` carries only session_id → subquery.
const scope = sessionIdInSourcesClause(sources, "session_id");
const clauses = [scope.clause];
const params = [...scope.params];
if (status) {
clauses.push("status = ?");
params.push(status);
}
rows = db
.prepare(
`SELECT * FROM agents WHERE ${clauses.join(" AND ")} ORDER BY started_at DESC LIMIT ? OFFSET ?`
)
.all(...params, limit, offset);
} else if (status) {
rows = stmts.listAgentsByStatus.all(status, limit, offset);
} else {
rows = stmts.listAgents.all(limit, offset);
}
// Attach each agent's OWN cost (from its metadata token buckets) so subagent
// cards can show their real cost instead of the session total.
res.json({ agents: attachAgentCosts(rows), limit, offset });
});
router.get("/:id", (req, res) => {
const agent = stmts.getAgent.get(req.params.id);
if (!agent) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Agent not found" } });
}
res.json({ agent });
});
router.post("/", (req, res) => {
const { id, session_id, name, type, subagent_type, status, task, parent_agent_id, metadata } =
req.body;
if (!id || !session_id || !name) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "id, session_id, and name are required" },
});
}
const existing = stmts.getAgent.get(id);
if (existing) {
return res.json({ agent: existing, created: false });
}
stmts.insertAgent.run(
id,
session_id,
name,
type || "main",
subagent_type || null,
status || "waiting",
task || null,
parent_agent_id || null,
metadata ? JSON.stringify(metadata) : null
);
const agent = stmts.getAgent.get(id);
broadcast("agent_created", agent);
res.status(201).json({ agent, created: true });
});
router.patch("/:id", (req, res) => {
const { name, status, task, current_tool, ended_at, metadata } = req.body;
const existing = stmts.getAgent.get(req.params.id);
if (!existing) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Agent not found" } });
}
stmts.updateAgent.run(
name || null,
status || null,
task || null,
current_tool !== undefined ? current_tool : existing.current_tool,
ended_at || null,
metadata ? JSON.stringify(metadata) : null,
req.params.id
);
const agent = stmts.getAgent.get(req.params.id);
broadcast("agent_updated", agent);
res.json({ agent });
});
module.exports = router;
+155
View File
@@ -0,0 +1,155 @@
/**
* @file Express router for the rules-based alerting engine: CRUD for alert
* rules, the fired-alert feed with pagination and unacked filtering, and
* acknowledge endpoints. Rule evaluation itself lives in server/lib/alerts.js.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { v4: uuidv4 } = require("uuid");
const { stmts } = require("../db");
const { broadcast } = require("../websocket");
const { RULE_TYPES, validateRuleConfig, invalidateRuleCache } = require("../lib/alerts");
const router = Router();
function serializeRule(row) {
let config = {};
try {
config = JSON.parse(row.config || "{}");
} catch {
/* surface as empty config rather than failing the request */
}
return { ...row, config, enabled: row.enabled === 1 };
}
// GET /api/alerts/rules - List all alert rules
router.get("/rules", (_req, res) => {
res.json({ rules: stmts.listAlertRules.all().map(serializeRule) });
});
// POST /api/alerts/rules - Create an alert rule
router.post("/rules", (req, res) => {
const { name, rule_type, config, enabled, cooldown_seconds } = req.body || {};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "name is required" },
});
}
const validated = validateRuleConfig(rule_type, config);
if (!validated.ok) {
return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } });
}
const cooldown =
cooldown_seconds == null ? 300 : Number.isInteger(cooldown_seconds) ? cooldown_seconds : -1;
if (cooldown < 0) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" },
});
}
const id = uuidv4();
stmts.insertAlertRule.run(
id,
name.trim(),
rule_type,
JSON.stringify(validated.config),
enabled === false ? 0 : 1,
cooldown
);
invalidateRuleCache();
res.status(201).json({ rule: serializeRule(stmts.getAlertRule.get(id)) });
});
// PATCH /api/alerts/rules/:id - Update an alert rule (partial)
router.patch("/rules/:id", (req, res) => {
const existing = stmts.getAlertRule.get(req.params.id);
if (!existing) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert rule not found" } });
}
const { name, config, enabled, cooldown_seconds } = req.body || {};
if (name != null && (typeof name !== "string" || !name.trim())) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "name must be a non-empty string" },
});
}
let configJson = null;
if (config != null) {
// rule_type is immutable — validate the new config against the stored type
const validated = validateRuleConfig(existing.rule_type, config);
if (!validated.ok) {
return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } });
}
configJson = JSON.stringify(validated.config);
}
if (cooldown_seconds != null && (!Number.isInteger(cooldown_seconds) || cooldown_seconds < 0)) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" },
});
}
stmts.updateAlertRule.run(
name != null ? name.trim() : null,
configJson,
enabled == null ? null : enabled ? 1 : 0,
cooldown_seconds ?? null,
req.params.id
);
invalidateRuleCache();
res.json({ rule: serializeRule(stmts.getAlertRule.get(req.params.id)) });
});
// DELETE /api/alerts/rules/:id - Delete an alert rule (its alert history
// cascades away with it — the FK is ON DELETE CASCADE)
router.delete("/rules/:id", (req, res) => {
const existing = stmts.getAlertRule.get(req.params.id);
if (!existing) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert rule not found" } });
}
stmts.deleteAlertRule.run(req.params.id);
invalidateRuleCache();
res.json({ ok: true });
});
// GET /api/alerts - Fired-alert feed, newest first. ?unacked=true filters to
// unacknowledged alerts; limit/offset paginate.
router.get("/", (req, res) => {
// Clamp to sane bounds — negative values would make SQLite's LIMIT/OFFSET
// misbehave (a negative LIMIT means "no limit").
const limit = Math.max(1, Math.min(parseInt(req.query.limit, 10) || 50, 200));
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const unackedOnly = req.query.unacked === "true";
const alerts = unackedOnly
? stmts.listUnackedAlertEvents.all(limit, offset)
: stmts.listAlertEvents.all(limit, offset);
const total = unackedOnly
? stmts.countUnackedAlertEvents.get().count
: stmts.countAlertEvents.get().count;
const unacked = stmts.countUnackedAlertEvents.get().count;
res.json({ alerts, total, unacked, limit, offset });
});
// POST /api/alerts/:id/ack - Acknowledge one alert
router.post("/:id(\\d+)/ack", (req, res) => {
const alert = stmts.getAlertEvent.get(req.params.id);
if (!alert) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert not found" } });
}
stmts.ackAlertEvent.run(req.params.id);
const updated = stmts.getAlertEvent.get(req.params.id);
broadcast("alert_updated", updated);
res.json({ alert: updated });
});
// POST /api/alerts/ack-all - Acknowledge every unacked alert
router.post("/ack-all", (_req, res) => {
const info = stmts.ackAllAlertEvents.run();
if (info.changes > 0) broadcast("alert_updated", { acked_all: true });
res.json({ ok: true, acknowledged: info.changes });
});
module.exports = router;
module.exports.RULE_TYPES = RULE_TYPES;
+89
View File
@@ -0,0 +1,89 @@
/**
* @file Express router for analytics endpoints, providing aggregated statistics on token usage, tool usage, daily events/sessions, agent types, and more. It queries the database for various metrics and returns them in a structured JSON format for frontend consumption.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { stmts, db } = require("../db");
const { parseSources } = require("../lib/source-filter");
const scoped = require("../lib/scoped-stats");
const { calculateCost } = require("./pricing");
const router = Router();
router.get("/", (req, res) => {
// Client sends tz_offset (minutes from getTimezoneOffset(), e.g. 420 for PDT)
// Negate it to get the SQLite modifier: 420 → '-420 minutes'
const rawOffset = parseInt(req.query.tz_offset, 10);
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
// Data-scope: restrict every metric to a subset of source machines when the
// user has chosen one; otherwise use the cached prepared statements.
const sources = parseSources(req);
const tokenTotals = sources ? scoped.tokenTotals(db, sources) : stmts.getTokenTotals.get();
const toolUsage = sources ? scoped.toolUsageCounts(db, sources) : stmts.toolUsageCounts.all();
const dailyEvents = sources
? scoped.dailyEventCounts(db, sources, tzModifier)
: stmts.dailyEventCounts.all(tzModifier);
const dailySessions = sources
? scoped.dailySessionCounts(db, sources, tzModifier)
: stmts.dailySessionCounts.all(tzModifier);
const agentTypes = sources
? scoped.agentTypeDistribution(db, sources)
: stmts.agentTypeDistribution.all();
const overview = sources ? scoped.statsOverview(db, sources) : stmts.stats.get();
const agentsByStatus = sources
? scoped.agentStatusCounts(db, sources)
: stmts.agentStatusCounts.all();
const sessionsByStatus = sources
? scoped.sessionStatusCounts(db, sources)
: stmts.sessionStatusCounts.all();
const totalSubagents = sources
? scoped.totalSubagentCount(db, sources)
: stmts.totalSubagentCount.get();
const eventTypes = sources ? scoped.eventTypeCounts(db, sources) : stmts.eventTypeCounts.all();
const avgEvents = sources
? scoped.avgEventsPerSession(db, sources)
: stmts.avgEventsPerSession.get();
// Calculate total cost across all sessions
const pricingRules = stmts.listPricing.all();
// Join the owning session's start date so each bucket is priced at the rate
// effective when it was used (date-effective promo rates, e.g. Sonnet 5 intro).
const allTokenUsage = sources
? scoped.scopedTokenUsageWithDate(db, sources)
: db
.prepare(
"SELECT tu.*, DATE(s.started_at) as date FROM token_usage tu JOIN sessions s ON s.id = tu.session_id"
)
.all();
let totalCost = 0;
for (const usage of allTokenUsage) {
const { total_cost } = calculateCost([usage], pricingRules);
totalCost += total_cost;
}
res.json({
tokens: {
total_input: tokenTotals?.total_input ?? 0,
total_output: tokenTotals?.total_output ?? 0,
total_cache_read: tokenTotals?.total_cache_read ?? 0,
total_cache_write: tokenTotals?.total_cache_write ?? 0,
},
total_cost: totalCost,
tool_usage: toolUsage,
daily_events: dailyEvents,
daily_sessions: dailySessions,
agent_types: agentTypes,
event_types: eventTypes,
avg_events_per_session: avgEvents?.avg ?? 0,
total_subagents: totalSubagents?.count ?? 0,
overview,
agents_by_status: Object.fromEntries(agentsByStatus.map((r) => [r.status, r.count])),
sessions_by_status: Object.fromEntries(sessionsByStatus.map((r) => [r.status, r.count])),
});
});
module.exports = router;

Some files were not shown because too many files have changed in this diff Show More