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.
211 lines
7.0 KiB
JavaScript
211 lines
7.0 KiB
JavaScript
/**
|
|
* @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);
|
|
});
|
|
});
|