Merge pull request 'feat(cli): unify uwf CAS store with global json-cas store' (#575) from fix/573-unify-cas-store into main
CI / check (push) Successful in 1m36s

feat(cli): unify uwf CAS store with global json-cas store

Fixes #573
This commit was merged in pull request #575.
This commit is contained in:
2026-05-30 05:25:12 +00:00
26 changed files with 409 additions and 21 deletions
+1 -1
View File
@@ -270,7 +270,7 @@ node scripts/publish-all.mjs --dry-run # preview without publishing
examples/solve-issue.yaml — write a workflow YAML definition examples/solve-issue.yaml — write a workflow YAML definition
│ uwf workflow put │ uwf workflow put
~/.uncaged/workflow/cas/ — Workflow stored as CAS node ~/.uncaged/json-cas/ — Workflow stored as CAS node (unified CAS store)
~/.uncaged/workflow/registry.yaml — name → hash mapping updated ~/.uncaged/workflow/registry.yaml — name → hash mapping updated
│ uwf thread start <name> -p "..." │ uwf thread start <name> -p "..."
+10 -1
View File
@@ -209,4 +209,13 @@ src/
| `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) | | `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) |
| `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash | | `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash |
| `~/.uncaged/workflow/threads.yaml` | Active thread head pointers | | `~/.uncaged/workflow/threads.yaml` | Active thread head pointers |
| `~/.uncaged/workflow/cas/` | Content-addressed node storage | | `~/.uncaged/json-cas/` | Content-addressed node storage (unified CAS store, shared with `json-cas` CLI) |
### Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `UNCAGED_CAS_DIR` | Override the global CAS directory location | `~/.uncaged/json-cas` |
| `UNCAGED_WORKFLOW_STORAGE_ROOT` | Internal override for workflow metadata storage | `~/.uncaged/workflow` |
| `WORKFLOW_STORAGE_ROOT` | User override for workflow metadata storage | `~/.uncaged/workflow` |
@@ -129,7 +129,11 @@ describe("C1: adapter JSON round-trip integration", () => {
{ {
encoding: "utf8", encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, WORKFLOW_STORAGE_ROOT: tmpDir }, env: {
...process.env,
WORKFLOW_STORAGE_ROOT: tmpDir,
UNCAGED_CAS_DIR: casDir,
},
cwd: tmpDir, cwd: tmpDir,
timeout: 30000, timeout: 30000,
}, },
@@ -6,14 +6,22 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdCasPutText } from "../commands/cas.js"; import { cmdCasPutText } from "../commands/cas.js";
let storageRoot: string; let storageRoot: string;
let casDir: string;
let uwfPath: string; let uwfPath: string;
let originalEnv: string | undefined;
beforeEach(async () => { beforeEach(async () => {
storageRoot = join( storageRoot = join(
tmpdir(), tmpdir(),
`uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, `uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
); );
casDir = join(storageRoot, "cas");
await mkdir(storageRoot, { recursive: true }); await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
// Find the uwf CLI path // Find the uwf CLI path
uwfPath = join(__dirname, "../../src/cli.ts"); uwfPath = join(__dirname, "../../src/cli.ts");
@@ -21,6 +29,13 @@ beforeEach(async () => {
afterEach(async () => { afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true }); await rm(storageRoot, { recursive: true, force: true });
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}); });
type ExecResult = { type ExecResult = {
@@ -32,7 +47,11 @@ type ExecResult = {
function execUwf(args: string[]): ExecResult { function execUwf(args: string[]): ExecResult {
try { try {
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, { const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot }, env: {
...process.env,
WORKFLOW_STORAGE_ROOT: storageRoot,
UNCAGED_CAS_DIR: casDir,
},
encoding: "utf-8", encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}); });
@@ -206,6 +206,8 @@ async function insertStepNode(
describe("currentRole field", () => { describe("currentRole field", () => {
let tmpDir: string; let tmpDir: string;
let storageRoot: string; let storageRoot: string;
let casDir: string;
let originalEnv: string | undefined;
async function setup() { async function setup() {
tmpDir = join( tmpDir = join(
@@ -213,13 +215,25 @@ describe("currentRole field", () => {
`uwf-test-current-role-${Date.now()}-${Math.random().toString(36).slice(2)}`, `uwf-test-current-role-${Date.now()}-${Math.random().toString(36).slice(2)}`,
); );
storageRoot = join(tmpDir, "storage"); storageRoot = join(tmpDir, "storage");
casDir = join(tmpDir, "cas");
await mkdir(storageRoot, { recursive: true }); await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
} }
async function teardown() { async function teardown() {
if (tmpDir) { if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
} }
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
} }
// T1: idle at start — currentRole = first role from graph // T1: idle at start — currentRole = first role from graph
@@ -66,13 +66,21 @@ function generateContent(size: number, prefix = "Content"): string {
// ── fixture ─────────────────────────────────────────────────────────────────── // ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string; let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => { beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-")); tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-"));
originalEnv = process.env.UNCAGED_CAS_DIR;
}); });
afterEach(async () => { afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}); });
// ── step read tests ─────────────────────────────────────────────────────────── // ── step read tests ───────────────────────────────────────────────────────────
@@ -80,7 +88,10 @@ afterEach(async () => {
describe("step read", () => { describe("step read", () => {
test("test 1: basic single-step read with 3 turns", async () => { test("test 1: basic single-step read with 3 turns", async () => {
const casDir = join(tmpDir, "cas"); const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store); const detailSchemas = await registerDetailSchemas(store);
@@ -166,7 +177,9 @@ describe("step read", () => {
test("test 2: quota enforcement - multiple turns", async () => { test("test 2: quota enforcement - multiple turns", async () => {
const casDir = join(tmpDir, "cas"); const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store); const detailSchemas = await registerDetailSchemas(store);
@@ -250,7 +263,9 @@ describe("step read", () => {
test("test 3: minimal quota edge case - always show at least one turn", async () => { test("test 3: minimal quota edge case - always show at least one turn", async () => {
const casDir = join(tmpDir, "cas"); const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store); const detailSchemas = await registerDetailSchemas(store);
@@ -325,7 +340,9 @@ describe("step read", () => {
test("test 4: step with no detail field", async () => { test("test 4: step with no detail field", async () => {
const casDir = join(tmpDir, "cas"); const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
@@ -384,7 +401,9 @@ describe("step read", () => {
test("test 5: step with detail but no turns array", async () => { test("test 5: step with detail but no turns array", async () => {
const casDir = join(tmpDir, "cas"); const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
await registerDetailSchemas(store); await registerDetailSchemas(store);
@@ -460,7 +479,9 @@ describe("step read", () => {
test("test 6: displays role and tool calls in turn body", async () => { test("test 6: displays role and tool calls in turn body", async () => {
const casDir = join(tmpDir, "cas"); const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store); const detailSchemas = await registerDetailSchemas(store);
@@ -532,7 +553,9 @@ describe("step read", () => {
test("test 7: turn content with special characters", async () => { test("test 7: turn content with special characters", async () => {
const casDir = join(tmpDir, "cas"); const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store); const detailSchemas = await registerDetailSchemas(store);
@@ -125,15 +125,23 @@ async function createTestStep(
describe("cmdStepShow JSON serialization", () => { describe("cmdStepShow JSON serialization", () => {
let testDir: string; let testDir: string;
let casDir: string; let casDir: string;
let originalEnv: string | undefined;
beforeEach(async () => { beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "uwf-test-")); testDir = await mkdtemp(join(tmpdir(), "uwf-test-"));
casDir = join(testDir, "cas"); casDir = join(testDir, "cas");
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
}); });
afterEach(async () => { afterEach(async () => {
await rm(testDir, { recursive: true, force: true }); await rm(testDir, { recursive: true, force: true });
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}); });
test("escapes newlines in tool call args", async () => { test("escapes newlines in tool call args", async () => {
@@ -63,13 +63,22 @@ async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
// ── fixture ────────────────────────────────────────────────────────────────── // ── fixture ──────────────────────────────────────────────────────────────────
let tmpDir: string; let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => { beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-")); tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-"));
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = join(tmpDir, "cas");
await mkdir(process.env.UNCAGED_CAS_DIR, { recursive: true });
}); });
afterEach(async () => { afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}); });
// ── 1. Protocol types (compile-time) ───────────────────────────────────────── // ── 1. Protocol types (compile-time) ─────────────────────────────────────────
@@ -0,0 +1,224 @@
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createUwfStore, getCasDir, getGlobalCasDir } from "../store.js";
describe("Global CAS directory", () => {
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
tmpDir = join(tmpdir(), `uwf-test-global-cas-${Date.now()}`);
await mkdir(tmpDir, { recursive: true });
originalEnv = process.env.UNCAGED_CAS_DIR;
});
afterEach(async () => {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
test("getGlobalCasDir returns default path when no env var set", () => {
delete process.env.UNCAGED_CAS_DIR;
const casDir = getGlobalCasDir();
// Should return ~/.uncaged/json-cas
expect(casDir).toContain(".uncaged");
expect(casDir).toContain("json-cas");
});
test("getGlobalCasDir respects UNCAGED_CAS_DIR environment variable", () => {
const customPath = join(tmpDir, "custom-cas");
process.env.UNCAGED_CAS_DIR = customPath;
const casDir = getGlobalCasDir();
expect(casDir).toBe(customPath);
});
test("getGlobalCasDir ignores empty UNCAGED_CAS_DIR", () => {
process.env.UNCAGED_CAS_DIR = "";
const casDir = getGlobalCasDir();
expect(casDir).toContain(".uncaged");
expect(casDir).toContain("json-cas");
});
test("getCasDir is deprecated but still works for backward compatibility", () => {
const storageRoot = join(tmpDir, "storage");
const casDir = getCasDir(storageRoot);
expect(casDir).toBe(join(storageRoot, "cas"));
});
test("createUwfStore uses global CAS directory", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
const uwf = await createUwfStore(storageRoot);
// Verify the store was created in the global CAS directory
expect(uwf.storageRoot).toBe(storageRoot);
expect(uwf.store).toBeDefined();
expect(uwf.schemas).toBeDefined();
// The global CAS directory should be created
const { stat } = await import("node:fs/promises");
const stats = await stat(globalCasDir);
expect(stats.isDirectory()).toBe(true);
});
test("createUwfStore creates global CAS directory if it does not exist", async () => {
const globalCasDir = join(tmpDir, "new-global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
await createUwfStore(storageRoot);
// Verify the directory was created
const { stat } = await import("node:fs/promises");
const stats = await stat(globalCasDir);
expect(stats.isDirectory()).toBe(true);
});
test("multiple uwfStore instances share the same global CAS filesystem", async () => {
const globalCasDir = join(tmpDir, "shared-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot1 = join(tmpDir, "storage1");
const storageRoot2 = join(tmpDir, "storage2");
await mkdir(storageRoot1, { recursive: true });
await mkdir(storageRoot2, { recursive: true });
const uwf1 = await createUwfStore(storageRoot1);
const uwf2 = await createUwfStore(storageRoot2);
// Both should use the same global CAS directory
expect(uwf1.store).toBeDefined();
expect(uwf2.store).toBeDefined();
// Store a node in the first store
const testData = { test: "data" };
const _hash = uwf1.store.put(uwf1.schemas.text, JSON.stringify(testData));
// Both stores share the same CAS filesystem directory
// Since schemas are registered idempotently, they should have the same hash
expect(uwf2.schemas.text).toBe(uwf1.schemas.text);
// Verify the CAS files are written to the shared directory
const { readdir } = await import("node:fs/promises");
const files = await readdir(globalCasDir);
expect(files.length).toBeGreaterThan(0);
});
test("workflow metadata remains in storageRoot, not global CAS", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
const _uwf = await createUwfStore(storageRoot);
// Write workflow registry file
const { saveWorkflowRegistry } = await import("../store.js");
await saveWorkflowRegistry(storageRoot, { "test-workflow": "ABC123" });
// Verify registry is in storageRoot, not global CAS
const { readFile } = await import("node:fs/promises");
const registryPath = join(storageRoot, "workflows.yaml");
const content = await readFile(registryPath, "utf8");
expect(content).toContain("test-workflow");
expect(content).toContain("ABC123");
// Verify registry is NOT in global CAS directory
const globalRegistryPath = join(globalCasDir, "workflows.yaml");
await expect(readFile(globalRegistryPath, "utf8")).rejects.toThrow();
});
test("thread metadata remains in storageRoot", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
await createUwfStore(storageRoot);
// Write threads index
const { saveThreadsIndex } = await import("../store.js");
await saveThreadsIndex(storageRoot, { "thread-123": "hash-456" });
// Verify threads.yaml is in storageRoot, not global CAS
const { readFile } = await import("node:fs/promises");
const threadsPath = join(storageRoot, "threads.yaml");
const content = await readFile(threadsPath, "utf8");
expect(content).toContain("thread-123");
expect(content).toContain("hash-456");
// Verify threads.yaml is NOT in global CAS directory
const globalThreadsPath = join(globalCasDir, "threads.yaml");
await expect(readFile(globalThreadsPath, "utf8")).rejects.toThrow();
});
test("history remains in storageRoot", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
await createUwfStore(storageRoot);
// Write history
const { appendThreadHistory } = await import("../store.js");
await appendThreadHistory(storageRoot, {
thread: "thread-123" as any,
workflow: "workflow-456",
head: "hash-789",
completedAt: Date.now(),
reason: "completed",
});
// Verify history.jsonl is in storageRoot, not global CAS
const { readFile } = await import("node:fs/promises");
const historyPath = join(storageRoot, "history.jsonl");
const content = await readFile(historyPath, "utf8");
expect(content).toContain("thread-123");
expect(content).toContain("workflow-456");
// Verify history.jsonl is NOT in global CAS directory
const globalHistoryPath = join(globalCasDir, "history.jsonl");
await expect(readFile(globalHistoryPath, "utf8")).rejects.toThrow();
});
test("CAS nodes are stored in global directory", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
const uwf = await createUwfStore(storageRoot);
// Store a CAS node
const testPayload = JSON.stringify({ test: "node" });
const _hash = uwf.store.put(uwf.schemas.text, testPayload);
// Verify the node is in global CAS directory
const { readdir } = await import("node:fs/promises");
const files = await readdir(globalCasDir);
expect(files.length).toBeGreaterThan(0);
// Verify the node is NOT in the old storageRoot/cas location
const oldCasDir = join(storageRoot, "cas");
await expect(readdir(oldCasDir)).rejects.toThrow();
});
});
@@ -15,6 +15,8 @@ import { appendThreadHistory, createUwfStore, saveThreadsIndex } from "../store.
async function makeUwfStore(storageRoot: string): Promise<UwfStore> { async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas"); const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
return createUwfStore(storageRoot); return createUwfStore(storageRoot);
} }
@@ -9,17 +9,31 @@ import { createUwfStore } from "../store.js";
describe("Thread and edge location integration", () => { describe("Thread and edge location integration", () => {
let tmpDir: string; let tmpDir: string;
let storageRoot: string; let storageRoot: string;
let casDir: string;
let originalEnv: string | undefined;
async function setupTestEnv() { async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-location-${Date.now()}`); tmpDir = join(tmpdir(), `uwf-test-location-${Date.now()}`);
storageRoot = join(tmpDir, "storage"); storageRoot = join(tmpDir, "storage");
casDir = join(tmpDir, "cas");
await mkdir(storageRoot, { recursive: true }); await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
} }
async function teardown() { async function teardown() {
if (tmpDir) { if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
} }
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
} }
test("thread start captures cwd in StartNode", async () => { test("thread start captures cwd in StartNode", async () => {
@@ -67,13 +67,22 @@ function generateContent(size: number, prefix = "Content"): string {
// ── fixture ─────────────────────────────────────────────────────────────────── // ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string; let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => { beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-")); tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-"));
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = join(tmpDir, "cas");
await mkdir(process.env.UNCAGED_CAS_DIR, { recursive: true });
}); });
afterEach(async () => { afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}); });
// ── thread read quota enforcement ───────────────────────────────────────────── // ── thread read quota enforcement ─────────────────────────────────────────────
@@ -143,7 +152,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test", agent: "uwf-test",
startedAtMs: 1000000000000, startedAtMs: 1000000000000,
completedAtMs: 1000000005000, completedAtMs: 1000000005000,
assembledPrompt: null, assembledPrompt: null,
}); });
steps.push(stepHash); steps.push(stepHash);
} }
@@ -339,7 +348,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test", agent: "uwf-test",
startedAtMs: 1000000000000, startedAtMs: 1000000000000,
completedAtMs: 1000000005000, completedAtMs: 1000000005000,
assembledPrompt: null, assembledPrompt: null,
}); });
steps.push(stepHash); steps.push(stepHash);
} }
@@ -497,7 +506,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test", agent: "uwf-test",
startedAtMs: 1000000000000, startedAtMs: 1000000000000,
completedAtMs: 1000000005000, completedAtMs: 1000000005000,
assembledPrompt: null, assembledPrompt: null,
}); });
steps.push(stepHash); steps.push(stepHash);
} }
@@ -579,7 +588,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test", agent: "uwf-test",
startedAtMs: 1000000000000, startedAtMs: 1000000000000,
completedAtMs: 1000000005000, completedAtMs: 1000000005000,
assembledPrompt: null, assembledPrompt: null,
}); });
steps.push(stepHash); steps.push(stepHash);
} }
@@ -53,6 +53,8 @@ const DETAIL_SCHEMA = {
async function makeUwfStore(storageRoot: string): Promise<UwfStore> { async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas"); const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas }; return { storageRoot, store, schemas };
@@ -696,7 +698,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test", agent: "uwf-test",
startedAtMs: 1000000000000, startedAtMs: 1000000000000,
completedAtMs: 1000000005000, completedAtMs: 1000000005000,
assembledPrompt: null, assembledPrompt: null,
})) as CasRef; })) as CasRef;
steps.push(step); steps.push(step);
prev = step; prev = step;
@@ -10,17 +10,31 @@ import { createUwfStore, loadThreadsIndex } from "../store.js";
describe("thread start --cwd CLI option", () => { describe("thread start --cwd CLI option", () => {
let tmpDir: string; let tmpDir: string;
let storageRoot: string; let storageRoot: string;
let casDir: string;
let originalEnv: string | undefined;
async function setupTestEnv() { async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-cwd-cli-${Date.now()}`); tmpDir = join(tmpdir(), `uwf-test-cwd-cli-${Date.now()}`);
storageRoot = join(tmpDir, "storage"); storageRoot = join(tmpDir, "storage");
casDir = join(tmpDir, "cas");
await mkdir(storageRoot, { recursive: true }); await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
} }
async function teardown() { async function teardown() {
if (tmpDir) { if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
} }
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
} }
async function createTestWorkflow(): Promise<string> { async function createTestWorkflow(): Promise<string> {
@@ -123,7 +137,7 @@ graph:
// Register the workflow // Register the workflow
execFileSync("node", [uwfBin, "workflow", "add", workflowPath], { execFileSync("node", [uwfBin, "workflow", "add", workflowPath], {
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot }, env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
encoding: "utf8", encoding: "utf8",
}); });
@@ -132,7 +146,7 @@ graph:
"node", "node",
[uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd], [uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd],
{ {
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot }, env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
encoding: "utf8", encoding: "utf8",
}, },
); );
@@ -58,6 +58,8 @@ const DETAIL_SCHEMA = {
async function makeUwfStore(storageRoot: string): Promise<UwfStore> { async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas"); const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas }; return { storageRoot, store, schemas };
@@ -15,6 +15,8 @@ import { loadWorkflowRegistry, saveWorkflowRegistry } from "../store.js";
async function makeUwfStore(storageRoot: string): Promise<UwfStore> { async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas"); const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas }; return { storageRoot, store, schemas };
+6 -1
View File
@@ -373,7 +373,12 @@ step
process.stderr.write("invalid --quota: must be a positive integer\n"); process.stderr.write("invalid --quota: must be a positive integer\n");
process.exit(1); process.exit(1);
} }
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota, opts.prompt === true); const markdown = await cmdStepRead(
storageRoot,
stepHash as CasRef,
quota,
opts.prompt === true,
);
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`); process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
}); });
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
export { export {
generateBootstrapReference as cmdSkillBootstrap,
generateAdapterReference as cmdSkillAdapter, generateAdapterReference as cmdSkillAdapter,
generateAuthorReference as cmdSkillAuthor, generateAuthorReference as cmdSkillAuthor,
generateBootstrapReference as cmdSkillBootstrap,
generateDeveloperReference as cmdSkillDeveloper, generateDeveloperReference as cmdSkillDeveloper,
generateUserReference as cmdSkillUser, generateUserReference as cmdSkillUser,
} from "@uncaged/workflow-util"; } from "@uncaged/workflow-util";
+4 -1
View File
@@ -311,7 +311,10 @@ export async function cmdStepRead(
if (promptNode === null) { if (promptNode === null) {
return `# Step ${stepHash}\n\n_Prompt CAS node not found: ${promptRef}_`; return `# Step ${stepHash}\n\n_Prompt CAS node not found: ${promptRef}_`;
} }
const promptText = typeof promptNode.payload === "string" ? promptNode.payload : JSON.stringify(promptNode.payload); const promptText =
typeof promptNode.payload === "string"
? promptNode.payload
: JSON.stringify(promptNode.payload);
return `# Step ${stepHash}\n\n**Role:** ${payload.role}\n**Agent:** ${payload.agent}\n\n## Prompt\n\n${promptText}`; return `# Step ${stepHash}\n\n**Role:** ${payload.role}\n**Agent:** ${payload.agent}\n\n## Prompt\n\n${promptText}`;
} }
+17 -1
View File
@@ -70,10 +70,26 @@ export function resolveStorageRoot(): string {
return getDefaultStorageRoot(); return getDefaultStorageRoot();
} }
/**
* Deprecated: Use `getGlobalCasDir()` instead.
* Returns the old CAS directory for backward compatibility.
*/
export function getCasDir(storageRoot: string): string { export function getCasDir(storageRoot: string): string {
return join(storageRoot, "cas"); return join(storageRoot, "cas");
} }
/**
* Returns the global CAS directory shared by all uwf and json-cas tools.
* Priority: UNCAGED_CAS_DIR environment variable → default ~/.uncaged/json-cas
*/
export function getGlobalCasDir(): string {
const envPath = process.env.UNCAGED_CAS_DIR;
if (envPath !== undefined && envPath !== "") {
return envPath;
}
return join(homedir(), ".uncaged", "json-cas");
}
export function getRegistryPath(storageRoot: string): string { export function getRegistryPath(storageRoot: string): string {
return join(storageRoot, "workflows.yaml"); return join(storageRoot, "workflows.yaml");
} }
@@ -98,7 +114,7 @@ export type UwfStore = {
}; };
export async function createUwfStore(storageRoot: string): Promise<UwfStore> { export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = getCasDir(storageRoot); const casDir = getGlobalCasDir();
await mkdir(casDir, { recursive: true }); await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir); const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store); const schemas = await registerUwfSchemas(store);
+6 -1
View File
@@ -94,7 +94,12 @@ async function runBuiltinWithMessages(
session.startedAtMs, session.startedAtMs,
); );
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId, assembledPrompt: "" }; return {
output: stripPreamble(loopResult.finalText),
detailHash,
sessionId: session.sessionId,
assembledPrompt: "",
};
} }
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> { async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
@@ -120,7 +120,11 @@ function spawnClaudeResume(
return spawnClaude(args); return spawnClaude(args);
} }
async function processClaudeOutput(stdout: string, store: Store, assembledPrompt: string): Promise<AgentRunResult> { async function processClaudeOutput(
stdout: string,
store: Store,
assembledPrompt: string,
): Promise<AgentRunResult> {
const parsed = parseClaudeCodeStreamOutput(stdout); const parsed = parseClaudeCodeStreamOutput(stdout);
if (parsed !== null) { if (parsed !== null) {
@@ -14,7 +14,7 @@ export const editNodeViewModel = define.view("editNodeView", editNodeView, (set,
function start(nodeId: string) { function start(nodeId: string) {
const [nodes] = model.use(nodesModel); const [nodes] = model.use(nodesModel);
const node = nodes.find((n) => n.id === nodeId); const node = nodes.find((n) => n.id === nodeId);
if (!node || node.type !== "role") return; if (node?.type !== "role") return;
set({ node: node as WorkNode<"role"> }); set({ node: node as WorkNode<"role"> });
} }
@@ -40,7 +40,7 @@ function traverse(
visited.add(nodeId); visited.add(nodeId);
const node = nodeMap.get(nodeId); const node = nodeMap.get(nodeId);
if (!node || node.type !== "role") return; if (node?.type !== "role") return;
const roleNode = node as WorkNode<"role">; const roleNode = node as WorkNode<"role">;
const outEdges = outgoingEdges.get(nodeId) ?? []; const outEdges = outgoingEdges.get(nodeId) ?? [];
@@ -25,7 +25,7 @@ describe("Protocol types for thread/edge location", () => {
edgePrompt: "Plan the implementation", edgePrompt: "Plan the implementation",
startedAtMs: Date.now(), startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000, completedAtMs: Date.now() + 1000,
assembledPrompt: null, assembledPrompt: null,
cwd: "/home/user/project", cwd: "/home/user/project",
}; };
+1 -1
View File
@@ -1,9 +1,9 @@
export { generateActorReference } from "./actor-reference.js"; export { generateActorReference } from "./actor-reference.js";
export { generateBootstrapReference } from "./bootstrap-reference.js";
export { generateAdapterReference } from "./adapter-reference.js"; export { generateAdapterReference } from "./adapter-reference.js";
export { generateArchitectureReference } from "./architecture-reference.js"; export { generateArchitectureReference } from "./architecture-reference.js";
export { generateAuthorReference } from "./author-reference.js"; export { generateAuthorReference } from "./author-reference.js";
export { encodeUint64AsCrockford } from "./base32.js"; export { encodeUint64AsCrockford } from "./base32.js";
export { generateBootstrapReference } from "./bootstrap-reference.js";
export { generateCliReference } from "./cli-reference.js"; export { generateCliReference } from "./cli-reference.js";
export { generateDeveloperReference } from "./developer-reference.js"; export { generateDeveloperReference } from "./developer-reference.js";
export { env } from "./env.js"; export { env } from "./env.js";