2ef004eecf
Reorganize flat cmd-*.ts files into commands/{workflow,thread,cas,init}/
subdirectories that strictly mirror the CLI subcommand hierarchy:
- workflow/: add, list, show, rm, history, rollback
- thread/: run, list, show, rm, fork, ps, kill, live, pause, resume
- cas/: get, put, list, rm, gc
- init/: workspace, template
Each group has an index.ts re-export. Split multi-command files
(cmd-cas.ts, cmd-thread.ts, cmd-init.ts) into per-subcommand files.
Rename cmd-help.ts → skill.ts to match the primary command name.
Update all import paths in cli-dispatch.ts and test files.
Pure structural change — no logic modifications.
Ref: #93, closes #94
166 lines
5.3 KiB
TypeScript
166 lines
5.3 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,
|
|
putContentMerkleNode,
|
|
} from "@uncaged/workflow";
|
|
import { cmdThreadRemove } from "../src/commands/thread/rm.js";
|
|
import { pathExists } from "../src/fs-utils.js";
|
|
|
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
|
|
|
async function writeDemoDataJsonl(params: {
|
|
path: string;
|
|
threadId: string;
|
|
bundleHash: string;
|
|
cas: ReturnType<typeof createCasStore>;
|
|
activeHash: string;
|
|
}): Promise<void> {
|
|
const bodyHash = await putContentMerkleNode(params.cas, "p");
|
|
const text = [
|
|
JSON.stringify({
|
|
name: "demo",
|
|
hash: params.bundleHash,
|
|
threadId: params.threadId,
|
|
parameters: { prompt: "hi", options: { maxRounds: 5 } },
|
|
timestamp: 100,
|
|
}),
|
|
JSON.stringify({
|
|
role: "planner",
|
|
contentHash: bodyHash,
|
|
meta: {},
|
|
refs: [params.activeHash, bodyHash],
|
|
timestamp: 101,
|
|
}),
|
|
"",
|
|
].join("\n");
|
|
await writeFile(params.path, text, "utf8");
|
|
}
|
|
|
|
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 writeDemoDataJsonl({
|
|
path: join(logsDir, `${threadId}.data.jsonl`),
|
|
threadId,
|
|
bundleHash,
|
|
cas,
|
|
activeHash,
|
|
});
|
|
|
|
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(2);
|
|
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 writeDemoDataJsonl({
|
|
path: join(logsDir, `${threadId}.data.jsonl`),
|
|
threadId,
|
|
bundleHash,
|
|
cas,
|
|
activeHash,
|
|
});
|
|
|
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
|
const proc = spawnSync(process.execPath, [cliEntryPath, "cas", "gc"], {
|
|
env,
|
|
encoding: "utf8",
|
|
});
|
|
expect(proc.status).toBe(0);
|
|
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 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 writeDemoDataJsonl({
|
|
path: join(logsDir, `${threadId}.data.jsonl`),
|
|
threadId,
|
|
bundleHash,
|
|
cas,
|
|
activeHash,
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|