feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user