6488b7bbb4
- garbageCollectCas() mark-and-sweep: scan .data.jsonl refs, delete orphans - 'uncaged-workflow gc' CLI command - thread rm triggers GC automatically - 141 tests passing Fixes #32
145 lines
5.1 KiB
TypeScript
145 lines
5.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
|
|
import { cmdThreadRemove } from "../src/cmd-thread.js";
|
|
import { pathExists } from "../src/fs-utils.js";
|
|
|
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
|
|
|
/** Minimal valid `.data.jsonl` with one role step referencing `activeHash` in `refs`. */
|
|
function makeDataJsonl(threadId: string, bundleHash: string, activeHash: string): string {
|
|
return [
|
|
JSON.stringify({
|
|
name: "demo",
|
|
hash: bundleHash,
|
|
threadId,
|
|
parameters: { prompt: "hi", options: { maxRounds: 5 } },
|
|
timestamp: 100,
|
|
}),
|
|
JSON.stringify({
|
|
role: "planner",
|
|
content: "p",
|
|
meta: {},
|
|
refs: [activeHash],
|
|
timestamp: 101,
|
|
}),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
describe("gc cli and garbageCollectCas", () => {
|
|
let prevEnv: string | undefined;
|
|
let storageRoot: string;
|
|
|
|
beforeEach(async () => {
|
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-gc-"));
|
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (prevEnv === undefined) {
|
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
|
} else {
|
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
|
}
|
|
await rm(storageRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test("garbageCollectCas keeps CAS entries referenced by thread refs", async () => {
|
|
const bundleHash = "C9NMV6V2TQT81";
|
|
const threadId = "01AAA1111111111111111111";
|
|
const logsDir = join(storageRoot, "logs", bundleHash);
|
|
await mkdir(logsDir, { recursive: true });
|
|
|
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
const activeHash = await cas.put("active-blob");
|
|
const orphanHash = await cas.put("orphan-blob");
|
|
|
|
await writeFile(
|
|
join(logsDir, `${threadId}.data.jsonl`),
|
|
makeDataJsonl(threadId, bundleHash, activeHash),
|
|
"utf8",
|
|
);
|
|
|
|
const gc = await garbageCollectCas(storageRoot);
|
|
expect(gc.ok).toBe(true);
|
|
if (!gc.ok) {
|
|
return;
|
|
}
|
|
expect(gc.value.scannedThreads).toBe(1);
|
|
expect(gc.value.activeRefs).toBe(1);
|
|
expect(gc.value.deletedEntries).toBe(1);
|
|
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
|
|
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(true);
|
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
|
});
|
|
|
|
test("garbageCollectCas deletes orphaned CAS when no threads reference them", async () => {
|
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
const orphanHash = await cas.put("lonely");
|
|
|
|
const gc = await garbageCollectCas(storageRoot);
|
|
expect(gc.ok).toBe(true);
|
|
if (!gc.ok) {
|
|
return;
|
|
}
|
|
expect(gc.value.scannedThreads).toBe(0);
|
|
expect(gc.value.activeRefs).toBe(0);
|
|
expect(gc.value.deletedEntries).toBe(1);
|
|
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
|
});
|
|
|
|
test("cli gc prints stats", async () => {
|
|
const bundleHash = "C9NMV6V2TQT81";
|
|
const threadId = "01BBB2222222222222222222";
|
|
const logsDir = join(storageRoot, "logs", bundleHash);
|
|
await mkdir(logsDir, { recursive: true });
|
|
|
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
const activeHash = await cas.put("keep-me");
|
|
await cas.put("drop-me");
|
|
|
|
await writeFile(
|
|
join(logsDir, `${threadId}.data.jsonl`),
|
|
makeDataJsonl(threadId, bundleHash, activeHash),
|
|
"utf8",
|
|
);
|
|
|
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
|
const proc = spawnSync(process.execPath, [cliEntryPath, "gc"], { env, encoding: "utf8" });
|
|
expect(proc.status).toBe(0);
|
|
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 1 active refs, deleted 1 entries");
|
|
});
|
|
|
|
test("thread rm triggers gc so unreferenced CAS is removed", async () => {
|
|
const bundleHash = "C9NMV6V2TQT81";
|
|
const threadId = "01CCC3333333333333333333";
|
|
const logsDir = join(storageRoot, "logs", bundleHash);
|
|
await mkdir(logsDir, { recursive: true });
|
|
|
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
const activeHash = await cas.put("pinned-by-ref");
|
|
await writeFile(
|
|
join(logsDir, `${threadId}.data.jsonl`),
|
|
makeDataJsonl(threadId, bundleHash, activeHash),
|
|
"utf8",
|
|
);
|
|
|
|
const orphanHash = await cas.put("orphan-after-rm");
|
|
const orphanPath = join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`);
|
|
|
|
const removed = await cmdThreadRemove(storageRoot, threadId);
|
|
expect(removed.ok).toBe(true);
|
|
|
|
expect(await pathExists(orphanPath)).toBe(false);
|
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(false);
|
|
});
|
|
});
|