5cb70d702b
- Session sync leaves a transcript alone while hooks feed its session live (active, newest event a hook's, touched within DASHBOARD_SYNC_LIVE_HOOK_MS, default 5 min) and catches it up once quiet; a hook-created session (which importSession skips anyway) is only snapshotted, never parsed. - Hook writer keeps a per-transcript mark (applied after commit): an unchanged TranscriptCache result skips token/compaction/API-error/turn writes, a grown one walks only the entries after the last one handled. - Codex usage: one stamp query per pass instead of one row per journal; between full passes (first + every 5 min) only the newest day folders and journals written in the last hour are statted; fork parents resolved from the table or a full walk. - Cursor/Antigravity polls back off to 60 s after idle passes (server/lib/backoff-poll.js); lane sync runs only while another dashboard shares the data dir (checked every 30 s).
235 lines
8.9 KiB
JavaScript
235 lines
8.9 KiB
JavaScript
/**
|
|
* @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(), "agenthub-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"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("syncDefaultProjects — sessions hooks are feeding", () => {
|
|
const SNAP_DIR = path.join(process.env.DASHBOARD_DATA_DIR, "transcripts");
|
|
const more = (sessionId, cwd, ts) => ({
|
|
type: "assistant",
|
|
cwd,
|
|
sessionId,
|
|
timestamp: ts,
|
|
message: {
|
|
model: "claude-opus-4-8",
|
|
content: [{ type: "text", text: "later" }],
|
|
usage: { input_tokens: 3, output_tokens: 2 },
|
|
},
|
|
});
|
|
const bumpMtime = (file) => {
|
|
const future = Date.now() / 1000 + 120;
|
|
fs.utimesSync(file, future, future);
|
|
};
|
|
const ageSession = (id) =>
|
|
dbModule.db
|
|
.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?")
|
|
.run(new Date(Date.now() - 10 * 60_000).toISOString(), id);
|
|
|
|
it("defers a live hook-fed imported session, then catches it up once quiet", async () => {
|
|
const ID = "33333333-cccc-4ccc-8ccc-333333333333";
|
|
const cache = new Map();
|
|
const file = writeSession("-live", ID, fixtureLines(ID, "/live"));
|
|
await syncDefaultProjects(dbModule, { mtimeCache: cache });
|
|
assert.ok(dbModule.stmts.getSession.get(ID), "imported first");
|
|
|
|
// A hook arrives: its event is the newest, and it touches the session.
|
|
dbModule.stmts.insertEvent.run(ID, `${ID}-main`, "PreToolUse", "Bash", "hook", "{}");
|
|
dbModule.stmts.touchSession.run(ID);
|
|
writeSession(
|
|
"-live",
|
|
ID,
|
|
fixtureLines(ID, "/live", [more(ID, "/live", "2026-04-18T12:09:00.000Z")])
|
|
);
|
|
bumpMtime(file);
|
|
|
|
const live = await syncDefaultProjects(dbModule, { mtimeCache: cache });
|
|
assert.equal(
|
|
live.changed.find((c) => c.sessionId === ID),
|
|
undefined,
|
|
"deferred while live"
|
|
);
|
|
assert.equal(cache.get(file), -1, "kept in the cache so the watcher ignores its appends");
|
|
|
|
ageSession(ID); // the hooks went quiet
|
|
const quiet = await syncDefaultProjects(dbModule, { mtimeCache: cache });
|
|
assert.ok(
|
|
quiet.changed.find((c) => c.sessionId === ID && !c.isNew),
|
|
"caught up once quiet"
|
|
);
|
|
assert.notEqual(cache.get(file), -1);
|
|
});
|
|
|
|
it("never re-parses a hook-created session, only refreshes its snapshot once quiet", async () => {
|
|
const ID = "44444444-dddd-4ddd-8ddd-444444444444";
|
|
dbModule.stmts.insertSession.run(ID, "hooked", "active", "/hooked", null, null);
|
|
dbModule.stmts.insertEvent.run(ID, null, "SessionStart", null, "hook", "{}");
|
|
const file = writeSession("-hooked", ID, fixtureLines(ID, "/hooked"));
|
|
bumpMtime(file);
|
|
const snap = path.join(SNAP_DIR, `${ID}.jsonl`);
|
|
const cache = new Map();
|
|
|
|
await syncDefaultProjects(dbModule, { mtimeCache: cache });
|
|
assert.equal(fs.existsSync(snap), false, "no snapshot copy while hooks are live");
|
|
|
|
ageSession(ID);
|
|
const before = dbModule.db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?");
|
|
const n0 = before.get(ID).n;
|
|
const r = await syncDefaultProjects(dbModule, { mtimeCache: cache });
|
|
assert.equal(
|
|
r.changed.find((c) => c.sessionId === ID),
|
|
undefined,
|
|
"nothing imported"
|
|
);
|
|
assert.equal(before.get(ID).n, n0, "no events written for a hook-created session");
|
|
assert.equal(fs.existsSync(snap), true, "snapshot taken once quiet");
|
|
});
|
|
});
|