diff --git a/CLAUDE.md b/CLAUDE.md index 92b917b..5d1f6a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This monorepo implements a stateless workflow engine driven by a single-step CLI | **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. | | **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. | | **CAS** | Content-Addressed Storage via `@ocas/core` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. | -| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. | +| **Registry** | `~/.uwf/registry.yaml` — maps workflow names to current CAS hashes. | ### Monorepo Structure @@ -273,11 +273,11 @@ node scripts/publish-all.mjs --dry-run # preview without publishing examples/solve-issue.yaml — write a workflow YAML definition │ uwf workflow put ▼ -~/.uncaged/json-cas/ — Workflow stored as CAS node (unified CAS store) -~/.uncaged/workflow/registry.yaml — name → hash mapping updated +~/.ocas/ — Workflow stored as CAS node (unified CAS store) +~/.uwf/registry.yaml — name → hash mapping updated │ uwf thread start -p "..." ▼ -~/.uncaged/workflow/threads.yaml — new thread head pointer +~/.uwf/threads.yaml — new thread head pointer │ uwf thread step ▼ moderator → agent → extract — one step per invocation, repeat until $END diff --git a/packages/cli-workflow/src/__tests__/store-global-cas.test.ts b/packages/cli-workflow/src/__tests__/store-global-cas.test.ts index 13df2cf..c61bbc6 100644 --- a/packages/cli-workflow/src/__tests__/store-global-cas.test.ts +++ b/packages/cli-workflow/src/__tests__/store-global-cas.test.ts @@ -6,45 +6,64 @@ import { createUwfStore, getCasDir, getGlobalCasDir } from "../store.js"; describe("Global CAS directory", () => { let tmpDir: string; - let originalEnv: string | undefined; + let originalOcasDir: string | undefined; + let originalLegacyCasDir: string | undefined; beforeEach(async () => { tmpDir = join(tmpdir(), `uwf-test-global-cas-${Date.now()}`); await mkdir(tmpDir, { recursive: true }); - originalEnv = process.env.UNCAGED_CAS_DIR; + originalOcasDir = process.env.OCAS_DIR; + originalLegacyCasDir = process.env.UNCAGED_CAS_DIR; }); afterEach(async () => { if (tmpDir) { await rm(tmpDir, { recursive: true, force: true }); } - if (originalEnv === undefined) { + if (originalOcasDir === undefined) { + delete process.env.OCAS_DIR; + } else { + process.env.OCAS_DIR = originalOcasDir; + } + if (originalLegacyCasDir === undefined) { delete process.env.UNCAGED_CAS_DIR; } else { - process.env.UNCAGED_CAS_DIR = originalEnv; + process.env.UNCAGED_CAS_DIR = originalLegacyCasDir; } }); test("getGlobalCasDir returns default path when no env var set", () => { + delete process.env.OCAS_DIR; delete process.env.UNCAGED_CAS_DIR; const casDir = getGlobalCasDir(); - // Should return ~/.uncaged/json-cas - expect(casDir).toContain(".uncaged"); - expect(casDir).toContain("json-cas"); + expect(casDir).toContain(".ocas"); + }); + + test("getGlobalCasDir respects OCAS_DIR environment variable", () => { + const customPath = join(tmpDir, "custom-cas"); + process.env.OCAS_DIR = customPath; + const casDir = getGlobalCasDir(); + expect(casDir).toBe(customPath); }); test("getGlobalCasDir respects UNCAGED_CAS_DIR environment variable", () => { - const customPath = join(tmpDir, "custom-cas"); + const customPath = join(tmpDir, "legacy-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 = ""; + test("getGlobalCasDir prefers OCAS_DIR over UNCAGED_CAS_DIR", () => { + process.env.OCAS_DIR = join(tmpDir, "primary-cas"); + process.env.UNCAGED_CAS_DIR = join(tmpDir, "legacy-cas"); + expect(getGlobalCasDir()).toBe(join(tmpDir, "primary-cas")); + }); + + test("getGlobalCasDir ignores empty OCAS_DIR", () => { + process.env.OCAS_DIR = ""; + delete process.env.UNCAGED_CAS_DIR; const casDir = getGlobalCasDir(); - expect(casDir).toContain(".uncaged"); - expect(casDir).toContain("json-cas"); + expect(casDir).toContain(".ocas"); }); test("getCasDir is deprecated but still works for backward compatibility", () => { diff --git a/packages/cli-workflow/src/__tests__/store-storage-root.test.ts b/packages/cli-workflow/src/__tests__/store-storage-root.test.ts new file mode 100644 index 0000000..cc90711 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/store-storage-root.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { lstat, mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + getDefaultStorageRoot, + getGlobalCasDir, + migrateStorageIfNeeded, + resolveStorageRoot, +} from "../store.js"; + +describe("Storage root resolution", () => { + const envKeys = [ + "UWF_STORAGE_ROOT", + "WORKFLOW_STORAGE_ROOT", + "UNCAGED_WORKFLOW_STORAGE_ROOT", + "OCAS_DIR", + "UNCAGED_CAS_DIR", + ] as const; + const savedEnv: Partial> = {}; + + beforeEach(() => { + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + test("getDefaultStorageRoot returns ~/.uwf", () => { + expect(getDefaultStorageRoot()).toBe(join(homedir(), ".uwf")); + }); + + test("resolveStorageRoot prefers UWF_STORAGE_ROOT", () => { + process.env.UWF_STORAGE_ROOT = "/tmp/uwf-primary"; + process.env.WORKFLOW_STORAGE_ROOT = "/tmp/uwf-fallback"; + process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/uwf-legacy"; + expect(resolveStorageRoot()).toBe("/tmp/uwf-primary"); + }); + + test("resolveStorageRoot falls back to WORKFLOW_STORAGE_ROOT", () => { + process.env.WORKFLOW_STORAGE_ROOT = "/tmp/uwf-fallback"; + process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/uwf-legacy"; + expect(resolveStorageRoot()).toBe("/tmp/uwf-fallback"); + }); + + test("resolveStorageRoot falls back to UNCAGED_WORKFLOW_STORAGE_ROOT", () => { + process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/uwf-legacy"; + expect(resolveStorageRoot()).toBe("/tmp/uwf-legacy"); + }); + + test("getGlobalCasDir returns ~/.ocas by default", () => { + const casDir = getGlobalCasDir(); + expect(casDir).toBe(join(homedir(), ".ocas")); + }); + + test("getGlobalCasDir prefers OCAS_DIR over UNCAGED_CAS_DIR", () => { + process.env.OCAS_DIR = "/tmp/ocas-primary"; + process.env.UNCAGED_CAS_DIR = "/tmp/ocas-legacy"; + expect(getGlobalCasDir()).toBe("/tmp/ocas-primary"); + }); + + test("getGlobalCasDir falls back to UNCAGED_CAS_DIR", () => { + process.env.UNCAGED_CAS_DIR = "/tmp/ocas-legacy"; + expect(getGlobalCasDir()).toBe("/tmp/ocas-legacy"); + }); +}); + +describe("migrateStorageIfNeeded", () => { + let fakeHome: string; + + beforeEach(async () => { + fakeHome = join( + homedir(), + `.uwf-migrate-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(fakeHome, { recursive: true }); + }); + + afterEach(async () => { + await rm(fakeHome, { recursive: true, force: true }); + }); + + test("creates symlinks from legacy paths when new paths are missing", async () => { + const oldWorkflow = join(fakeHome, ".uncaged", "workflow"); + const oldCas = join(fakeHome, ".uncaged", "json-cas"); + await mkdir(oldWorkflow, { recursive: true }); + await mkdir(oldCas, { recursive: true }); + await writeFile(join(oldWorkflow, "config.yaml"), "defaultAgent: test\n", "utf8"); + + migrateStorageIfNeeded(fakeHome); + + const newWorkflow = join(fakeHome, ".uwf"); + const newCas = join(fakeHome, ".ocas"); + const workflowStat = await lstat(newWorkflow); + const casStat = await lstat(newCas); + expect(workflowStat.isSymbolicLink()).toBe(true); + expect(casStat.isSymbolicLink()).toBe(true); + }); + + test("skips migration when new paths already exist", async () => { + const oldWorkflow = join(fakeHome, ".uncaged", "workflow"); + const newWorkflow = join(fakeHome, ".uwf"); + await mkdir(oldWorkflow, { recursive: true }); + await mkdir(newWorkflow, { recursive: true }); + + migrateStorageIfNeeded(fakeHome); + + const stat = await lstat(newWorkflow); + expect(stat.isDirectory()).toBe(true); + }); +}); diff --git a/packages/cli-workflow/src/background/types.ts b/packages/cli-workflow/src/background/types.ts index c7993a5..d3a61b9 100644 --- a/packages/cli-workflow/src/background/types.ts +++ b/packages/cli-workflow/src/background/types.ts @@ -1,6 +1,6 @@ import type { CasRef, ThreadId } from "@united-workforce/protocol"; -/** Marker file stored at ~/.uncaged/workflow/running/.json */ +/** Marker file stored at ~/.uwf/running/.json */ export type RunningMarker = { thread: ThreadId; workflow: CasRef; diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts index 5c6fad7..de39e96 100755 --- a/packages/cli-workflow/src/cli.ts +++ b/packages/cli-workflow/src/cli.ts @@ -41,7 +41,9 @@ import { import { parseTimeInput } from "./commands/thread-time-parser.js"; import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js"; import { formatOutput, type OutputFormat } from "./format.js"; -import { resolveStorageRoot } from "./store.js"; +import { migrateStorageIfNeeded, resolveStorageRoot } from "./store.js"; + +migrateStorageIfNeeded(); function writeOutput(data: unknown): void { const fmt = program.opts().format as OutputFormat; diff --git a/packages/cli-workflow/src/store.ts b/packages/cli-workflow/src/store.ts index eea6b6c..687bb7b 100644 --- a/packages/cli-workflow/src/store.ts +++ b/packages/cli-workflow/src/store.ts @@ -1,4 +1,5 @@ import type { Dirent } from "node:fs"; +import { existsSync, symlinkSync } from "node:fs"; import { access, appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -105,27 +106,55 @@ export async function discoverProjectWorkflows( return merged; } -/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */ +/** Default filesystem root for uwf data (`~/.uwf`). */ export function getDefaultStorageRoot(): string { - return join(homedir(), ".uncaged", "workflow"); + return join(homedir(), ".uwf"); } /** * Resolve storage root. - * Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default. + * Priority: `UWF_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → `UNCAGED_WORKFLOW_STORAGE_ROOT` (legacy) → default. */ export function resolveStorageRoot(): string { - const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT; - if (internal !== undefined && internal !== "") { - return internal; + const primary = process.env.UWF_STORAGE_ROOT; + if (primary !== undefined && primary !== "") { + return primary; } const userOverride = process.env.WORKFLOW_STORAGE_ROOT; if (userOverride !== undefined && userOverride !== "") { return userOverride; } + const legacy = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT; + if (legacy !== undefined && legacy !== "") { + return legacy; + } return getDefaultStorageRoot(); } +/** Symlink legacy storage paths to ~/.uwf and ~/.ocas when upgrading from older installs. */ +export function migrateStorageIfNeeded(home: string = homedir()): void { + const oldPath = join(home, ".uncaged", "workflow"); + const newPath = join(home, ".uwf"); + + if (!existsSync(newPath) && existsSync(oldPath)) { + symlinkSync(oldPath, newPath); + // biome-ignore lint/suspicious/noConsole: migration notice + console.log("⚠️ Storage migrated: ~/.uwf → ~/.uncaged/workflow (symlink)"); + // biome-ignore lint/suspicious/noConsole: migration notice + console.log( + " This symlink is temporary. Copy your data to ~/.uwf/ and remove the symlink in a future version.", + ); + } + + const oldCas = join(home, ".uncaged", "json-cas"); + const newCas = join(home, ".ocas"); + if (!existsSync(newCas) && existsSync(oldCas)) { + symlinkSync(oldCas, newCas); + // biome-ignore lint/suspicious/noConsole: migration notice + console.log("⚠️ CAS storage migrated: ~/.ocas → ~/.uncaged/json-cas (symlink)"); + } +} + /** * Deprecated: Use `getGlobalCasDir()` instead. * Returns the old CAS directory for backward compatibility. @@ -135,15 +164,19 @@ export function getCasDir(storageRoot: string): string { } /** - * Returns the global CAS directory shared by all uwf and json-cas tools. - * Priority: UNCAGED_CAS_DIR environment variable → default ~/.uncaged/json-cas + * Returns the global CAS directory shared by all uwf and ocas tools. + * Priority: `OCAS_DIR` → `UNCAGED_CAS_DIR` (legacy) → default ~/.ocas */ export function getGlobalCasDir(): string { - const envPath = process.env.UNCAGED_CAS_DIR; - if (envPath !== undefined && envPath !== "") { - return envPath; + const primary = process.env.OCAS_DIR; + if (primary !== undefined && primary !== "") { + return primary; } - return join(homedir(), ".uncaged", "json-cas"); + const legacy = process.env.UNCAGED_CAS_DIR; + if (legacy !== undefined && legacy !== "") { + return legacy; + } + return join(homedir(), ".ocas"); } export function getRegistryPath(storageRoot: string): string { diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index a5e8d95..f1c4264 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -113,7 +113,7 @@ export type StepOutput = { background: boolean | null; }; -/** Active thread entry in ~/.uncaged/workflow/threads.yaml */ +/** Active thread entry in ~/.uwf/threads.yaml */ export type ThreadIndexEntry = { head: CasRef; suspendedRole: string | null; @@ -199,7 +199,7 @@ export type AgentConfig = { args: string[]; }; -/** ~/.uncaged/workflow/config.yaml */ +/** ~/.uwf/config.yaml */ export type WorkflowConfig = { providers: Record; models: Record; @@ -210,5 +210,5 @@ export type WorkflowConfig = { modelOverrides: Record | null; }; -/** ~/.uncaged/workflow/threads.yaml */ +/** ~/.uwf/threads.yaml */ export type ThreadsIndex = Record; diff --git a/packages/workflow-util-agent/src/storage.ts b/packages/workflow-util-agent/src/storage.ts index 81ccef3..386562b 100644 --- a/packages/workflow-util-agent/src/storage.ts +++ b/packages/workflow-util-agent/src/storage.ts @@ -21,24 +21,28 @@ import { parse } from "yaml"; import { registerAgentSchemas } from "./schemas.js"; -/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */ +/** Default filesystem root for uwf data (`~/.uwf`). */ export function getDefaultStorageRoot(): string { - return join(homedir(), ".uncaged", "workflow"); + return join(homedir(), ".uwf"); } /** * Resolve storage root. - * Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default. + * Priority: `UWF_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → `UNCAGED_WORKFLOW_STORAGE_ROOT` (legacy) → default. */ export function resolveStorageRoot(): string { - const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT; - if (internal !== undefined && internal !== "") { - return internal; + const primary = process.env.UWF_STORAGE_ROOT; + if (primary !== undefined && primary !== "") { + return primary; } const userOverride = process.env.WORKFLOW_STORAGE_ROOT; if (userOverride !== undefined && userOverride !== "") { return userOverride; } + const legacy = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT; + if (legacy !== undefined && legacy !== "") { + return legacy; + } return getDefaultStorageRoot(); } diff --git a/packages/workflow-util/src/adapter-reference.ts b/packages/workflow-util/src/adapter-reference.ts index d451557..f9582b2 100644 --- a/packages/workflow-util/src/adapter-reference.ts +++ b/packages/workflow-util/src/adapter-reference.ts @@ -122,7 +122,7 @@ The \`detailHash\` is preserved from the first \`run()\` call — retry \`contin ## Registration -Register your adapter in \`~/.uncaged/workflow/config.yaml\`: +Register your adapter in \`~/.uwf/config.yaml\`: \`\`\`yaml agents: diff --git a/packages/workflow-util/src/architecture-reference.ts b/packages/workflow-util/src/architecture-reference.ts index 59a80e8..dbe9ab2 100644 --- a/packages/workflow-util/src/architecture-reference.ts +++ b/packages/workflow-util/src/architecture-reference.ts @@ -51,7 +51,7 @@ uwf thread exec ## Storage Layout -All data lives under \`~/.uncaged/workflow/\`: +All data lives under \`~/.uwf/\`: - \`cas/\` — content-addressed store (XXH64-keyed) - \`threads.yaml\` — active thread index - \`history.jsonl\` — completed thread archive diff --git a/packages/workflow-util/src/index.ts b/packages/workflow-util/src/index.ts index 55c7780..ab81113 100644 --- a/packages/workflow-util/src/index.ts +++ b/packages/workflow-util/src/index.ts @@ -28,7 +28,11 @@ export type { export { createProcessLogger } from "./process-logger/index.js"; export { normalizeRefsField } from "./refs-field.js"; export { err, ok } from "./result.js"; -export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js"; +export { + getDefaultStorageRoot, + getDefaultWorkflowStorageRoot, + getGlobalCasDir, +} from "./storage-root.js"; export type { LogFn, Result } from "./types.js"; export { extractUlidTimestamp, generateUlid } from "./ulid.js"; export { generateUserReference } from "./user-reference.js"; diff --git a/packages/workflow-util/src/process-logger/process-logger.ts b/packages/workflow-util/src/process-logger/process-logger.ts index 7967929..781ee09 100644 --- a/packages/workflow-util/src/process-logger/process-logger.ts +++ b/packages/workflow-util/src/process-logger/process-logger.ts @@ -1,7 +1,7 @@ import { appendFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -import { getDefaultWorkflowStorageRoot } from "../storage-root.js"; +import { getDefaultStorageRoot } from "../storage-root.js"; import { assertValidLogTag } from "./log-tag.js"; import type { CreateProcessLoggerOptions, ProcessLogger, ProcessLoggerContext } from "./types.js"; @@ -52,7 +52,7 @@ function appendEntry(filePath: string, entry: Record): void { /** Process-scoped debug logger — append-only JSONL under `/logs/YYYY-MM-DD.jsonl`. */ export function createProcessLogger(options: CreateProcessLoggerOptions): ProcessLogger { - const storageRoot = options.storageRoot ?? getDefaultWorkflowStorageRoot(); + const storageRoot = options.storageRoot ?? getDefaultStorageRoot(); const processId = `${Date.now()}-${process.pid}`; const baseContext = options.context; const logFilePath = getProcessLogFilePath(storageRoot, new Date()); diff --git a/packages/workflow-util/src/storage-root.ts b/packages/workflow-util/src/storage-root.ts index f270645..fecffb2 100644 --- a/packages/workflow-util/src/storage-root.ts +++ b/packages/workflow-util/src/storage-root.ts @@ -1,13 +1,18 @@ import { homedir } from "node:os"; import { join } from "node:path"; -/** Default filesystem root for workflow data (`~/.uncaged/workflow`). */ +/** Default filesystem root for workflow data (`~/.uwf`). */ +export function getDefaultStorageRoot(): string { + return join(homedir(), ".uwf"); +} + +/** @deprecated Use `getDefaultStorageRoot` instead. */ export function getDefaultWorkflowStorageRoot(): string { - return join(homedir(), ".uncaged", "workflow"); + return getDefaultStorageRoot(); } /** Global content-addressed store directory under the workflow storage root (`/cas`). */ export function getGlobalCasDir(storageRoot: string | undefined): string { - const root = storageRoot ?? getDefaultWorkflowStorageRoot(); + const root = storageRoot ?? getDefaultStorageRoot(); return join(root, "cas"); } diff --git a/packages/workflow-util/src/user-reference.ts b/packages/workflow-util/src/user-reference.ts index 29cd9b0..fc8dd65 100644 --- a/packages/workflow-util/src/user-reference.ts +++ b/packages/workflow-util/src/user-reference.ts @@ -37,7 +37,7 @@ uwf setup --provider --base-url \\ [--agent ] # optional default agent \`\`\` -Config is stored at \`~/.uncaged/workflow/config.yaml\`. Override storage root with \`UNCAGED_WORKFLOW_STORAGE_ROOT\`. +Config is stored at \`~/.uwf/config.yaml\`. Override storage root with \`UWF_STORAGE_ROOT\` (or \`WORKFLOW_STORAGE_ROOT\`). ## Workflow Commands