- Rewrite docs/architecture.md with 15-package map, dependency graph, updated engine paths - Update CLAUDE.md monorepo structure section - Add READMEs for: workflow-protocol, workflow-runtime, workflow-util, workflow-cas, workflow-register, workflow-execute, workflow-reactor - Fix agent READMEs: update deps from @uncaged/workflow to actual packages - Mark workflow-as-agent plan as outdated Fixes #153 小橘 <xiaoju@shazhou.work>
11 KiB
Workflow-as-Agent Implementation Plan
⚠️ This plan references the pre-split package structure. File paths have changed.
For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.
Goal: Enable workflows to invoke other workflows as agents, backed by global CAS and refs tracking.
Architecture: Migrate CAS from thread-local to global (~/.uncaged/workflow/cas/), add refs to RoleStep for GC traceability, then build workflowAsAgent(name) factory that resolves workflow name → bundle via registry and spawns a child thread.
Tech Stack: TypeScript, Bun, Zod v4, monorepo with packages/
Issue: uncaged/workflow#25
Phase 1: Global CAS Migration
Move CAS storage from <threadDir>/<threadId>.cas/ to ~/.uncaged/workflow/cas/ (global, content-addressed, immutable). This is a breaking change — thread-local .cas/ directories are abandoned.
Task 1.1: Add globalCasDir helper to storage-root.ts
Objective: Provide a single function that returns the global CAS directory path.
Files:
- Modify:
packages/workflow/src/storage-root.ts - Test:
packages/workflow/__tests__/storage-root.test.ts
Implementation:
// storage-root.ts — add export
export function getGlobalCasDir(storageRoot?: string): string {
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
return join(root, "cas");
}
Export from packages/workflow/src/index.ts.
Task 1.2: Update cmd-cas.ts to use global CAS
Objective: CLI cas get/put/list/rm no longer needs threadId for storage location — CAS is global. But keep threadId in CLI for backward compat of planner/coder prompts (they pass threadId).
Files:
- Modify:
packages/cli-workflow/src/cmd-cas.ts
Changes:
resolveCasDir→ usegetGlobalCasDir(storageRoot)instead of deriving from thread data pathcmdCasPut/cmdCasGet/cmdCasList/cmdCasRm: threadId is still accepted (prompts pass it) but storage goes to global dir- Remove the
resolveThreadDataPathdependency for CAS operations — thread doesn't need to exist to read CAS
import { createThreadCas, getGlobalCasDir } from "@uncaged/workflow";
export async function cmdCasGet(
storageRoot: string,
_threadId: string, // kept for CLI compat, not used for path
hash: string,
): Promise<Result<string, string>> {
const cas = createThreadCas(getGlobalCasDir(storageRoot));
const content = await cas.get(hash);
if (content === null) {
return err(`cas entry not found: ${hash}`);
}
return ok(content);
}
// ... same pattern for put/list/rm
Task 1.3: Update cmd-thread.ts — thread rm no longer deletes .cas/
Objective: Since CAS is global, thread rm should NOT delete CAS entries. CAS cleanup is GC's job.
Files:
- Modify:
packages/cli-workflow/src/cmd-thread.ts - Check: remove any
rmdir/unlinkof<threadId>.cas/directory
Task 1.4: Rename createThreadCas → createCasStore
Objective: The name createThreadCas is misleading now. Rename to createCasStore.
Files:
- Modify:
packages/workflow/src/cas.ts— rename function - Modify:
packages/workflow/src/index.ts— update export (keepcreateThreadCasas deprecated alias for one release) - Modify: all consumers (
cmd-cas.ts)
Task 1.5: Update tests
Objective: All CAS-related tests use global dir instead of thread-local.
Files:
- Modify:
packages/cli-workflow/__tests__/commands.test.ts - Verify:
bun testpasses
Task 1.6: Clean up old thread-local .cas/ references
Objective: Remove dead code that creates/reads thread-local .cas/ directories.
Files:
- Search all
*.tsfor.caspath construction patterns - Remove orphaned helpers
Phase 2: RoleStep refs Tracking
Add refs: string[] to persisted role steps so GC can trace which CAS entries are alive.
Task 2.1: Add refs to RoleOutput and engine persistence
Objective: Every role step can declare which CAS hashes it produced or consumed.
Files:
- Modify:
packages/workflow/src/types.ts - Modify:
packages/workflow/src/engine.ts
Changes to types.ts:
export type RoleOutput = {
role: string;
content: string;
meta: Record<string, unknown>;
refs: string[]; // CAS hashes produced/consumed by this step
};
Changes to engine.ts:
appendDataLinefor role steps: includerefsfield (default[]if not provided)
Task 2.2: Auto-populate refs from meta hashes
Objective: The engine should automatically extract CAS hashes from meta to populate refs, so roles don't need to manually track them.
Strategy: After meta extraction, walk the meta object and collect any string that looks like a CAS hash (Crockford Base32, 13 chars). This is a heuristic but works because CAS hashes are distinctive.
Alternative (simpler): Let each RoleDefinition optionally declare a extractRefs(meta: M) => string[] function. For planner, this returns meta.phases.map(p => p.hash). For coder, [meta.completedPhase].
Recommended: The explicit extractRefs approach — no magic, no false positives.
Files:
- Modify:
packages/workflow/src/types.ts— add optionalextractRefstoRoleDefinition - Modify:
packages/workflow/src/create-workflow.ts— callextractRefsafter meta extraction, set onRoleOutput.refs - Modify:
packages/workflow-role-planner/src/planner.ts— implementextractRefs - Modify:
packages/workflow-role-coder/src/coder.ts— implementextractRefs
// types.ts — RoleDefinition addition
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: z.ZodType<Meta>;
extractRefs?: (meta: Meta) => string[]; // CAS hashes to track
};
// planner.ts
extractRefs: (meta) => meta.phases.map(p => p.hash),
// coder.ts
extractRefs: (meta) => [meta.completedPhase],
Task 2.3: Update fork logic to preserve refs
Objective: When forking a thread, refs from historical steps must be carried over.
Files:
- Modify:
packages/workflow/src/fork-thread.ts - Verify:
ForkHistoricalStep/PrefilledDiskStepincluderefs
Task 2.4: Tests for refs tracking
Files:
- Add:
packages/workflow/__tests__/refs-tracking.test.ts - Verify: refs appear in
.data.jsonloutput
Phase 3: CAS Garbage Collection
Task 3.1: Implement gc.ts in @uncaged/workflow
Objective: Mark-and-sweep GC — scan all thread .data.jsonl files, collect refs, delete orphaned CAS entries.
Files:
- Create:
packages/workflow/src/gc.ts - Export from:
packages/workflow/src/index.ts
export type GcResult = {
scannedThreads: number;
activeRefs: number;
deletedEntries: number;
deletedHashes: string[];
};
export async function garbageCollectCas(storageRoot: string): Promise<GcResult> {
// 1. Find all .data.jsonl files under storageRoot
// 2. Parse each, flatMap step.refs → Set<string>
// 3. List all CAS entries via createCasStore(globalCasDir).list()
// 4. Delete entries not in active set
// 5. Return stats
}
Task 3.2: Add uncaged-workflow gc CLI command
Files:
- Create:
packages/cli-workflow/src/cmd-gc.ts - Modify:
packages/cli-workflow/src/cli-dispatch.ts— addgcsubcommand
Task 3.3: Run GC on thread rm
Files:
- Modify:
packages/cli-workflow/src/cmd-thread.ts— after deleting thread data, optionally run GC
Task 3.4: Tests for GC
Files:
- Create:
packages/cli-workflow/__tests__/gc-cli.test.ts
Phase 4: workflowAsAgent Factory
Task 4.1: Create workflowAsAgent in @uncaged/workflow
Objective: Factory function that takes a workflow name, resolves to bundle, returns an AgentFn.
Files:
- Create:
packages/workflow/src/workflow-as-agent.ts - Export from:
packages/workflow/src/index.ts
import type { AgentFn } from "./types.js";
export type WorkflowAsAgentOptions = {
storageRoot?: string;
};
export function workflowAsAgent(
workflowName: string,
options?: WorkflowAsAgentOptions,
): AgentFn {
return async (ctx) => {
const storageRoot = options?.storageRoot ?? getDefaultWorkflowStorageRoot();
// 1. Read registry → resolve name to bundle hash + path
const registry = await readWorkflowRegistry(storageRoot);
const entry = getRegisteredWorkflow(registry, workflowName);
if (entry === null) {
return `ERROR: workflow "${workflowName}" not found in registry`;
}
// 2. Load bundle
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExports = await extractBundleExports(bundlePath);
// 3. Create child thread input from ctx.start.content (parent prompt)
const input: ThreadInput = {
prompt: ctx.start.content,
steps: [],
};
// 4. Generate child threadId
const childThreadId = generateUlid();
// 5. Execute — collect all yields, return final content
const io: ExecuteThreadIo = { ... };
const result = await executeThread(bundleExports.run, workflowName, input, ...);
// 6. Return summary as agent content
return result.summary;
};
}
Task 4.2: System-level depth limit
Objective: Prevent infinite recursion. Track depth via thread metadata, enforce a global max (default 3, configurable in workflow.yaml).
Files:
- Modify:
packages/workflow/src/types.ts— adddepthtoWorkflowFnOptions - Modify:
packages/workflow/src/workflow-as-agent.ts— increment depth, check limit - Modify: registry or config types for
maxDepthsetting
Task 4.3: Tests for workflowAsAgent
Files:
- Create:
packages/workflow/__tests__/workflow-as-agent.test.ts - Test: name resolution, depth limit, child thread execution
Task 4.4: Integration test — nested workflow
Objective: Create a minimal test workflow that calls another workflow via workflowAsAgent.
Files:
- Create:
packages/workflow/__tests__/workflow-as-agent-integration.test.ts
Execution Order
Phase 1 (Global CAS) → Phase 2 (refs) → Phase 3 (GC) → Phase 4 (workflowAsAgent)
Each phase is independently mergeable. Phase 3 depends on Phase 2 (needs refs to know what's alive). Phase 4 depends on Phase 1 (global CAS for cross-thread sharing).
Breaking Changes
- CAS storage location moves from
<thread>.cas/to~/.uncaged/workflow/cas/ RoleOutputgains requiredrefs: string[]field- Existing threads with thread-local CAS will lose access to old CAS data (acceptable — those are short-lived workflow artifacts)
createThreadCasrenamed tocreateCasStore(alias kept temporarily)