57dc91585d
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.
554 lines
22 KiB
JavaScript
554 lines
22 KiB
JavaScript
/**
|
|
* @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 });
|
|
}
|
|
});
|
|
});
|