# RFC-001: Workflow Engine Design **Author:** 小橘 🍊(NEKO TeamοΌ‰ **Date:** 2026-05-06 **Status:** Draft --- ## 1. Package Structure | Package | npm Name | Binary | |---------|----------|--------| | Core lib | `@uncaged/workflow` | β€” | | CLI | `@uncaged/cli-workflow` | `uncaged-workflow` | Future: `@uncaged/cli` umbrella, invoke via `uncaged workflow `. Monorepo uses **bun workspace**. ## 2. Workflow Physical Implementation A **Workflow** is a single-file ESM module that **named-exports** an **AsyncGenerator** function as `run` and workflow metadata as `descriptor`: ```typescript /** What each yield produces β€” one role's output. */ type RoleOutput = { role: string; content: string; meta: Record; }; /** What the generator returns when done. */ type WorkflowResult = { returnCode: number; summary: string; }; /** Input to a workflow β€” prompt + optional historical steps for fork/resume. */ type ThreadInput = { prompt: string; steps: RoleOutput[]; // [] for new thread, pre-filled for fork/resume }; /** The bundle contract β€” an AsyncGenerator, not a Promise. */ type WorkflowFn = ( input: ThreadInput, options: { threadId: string; maxRounds: number } ) => AsyncGenerator; ``` ### Why AsyncGenerator? The workflow **yields** each role output instead of writing to an injected writer or exporting a framework-specific shape: ```typescript // Example bundle β€” zero framework dependency (named exports only) export const descriptor = { description: "Fix auth bug", roles: { planner: { description: "Plans the fix", schema: { type: "object", properties: { files: { type: "array", items: { type: "string" } } } }, }, coder: { description: "Implements the plan", schema: { type: "object", properties: { diff: { type: "string" } } }, }, }, }; export const run = async function* (input, options) { const plan = await callLLM("plan: " + input.prompt); yield { role: "planner", content: plan, meta: { files: ["src/auth.ts"] } }; const code = await callLLM("implement: " + plan); yield { role: "coder", content: code, meta: { diff: "..." } }; return { returnCode: 0, summary: "Fixed auth bug" }; }; ``` **Engine controls the loop**, not the bundle: - Each `yield` β†’ engine writes to `.data.jsonl`, checks `AbortSignal`, handles pause/resume - `return` β†’ engine writes the final result, marks thread complete - **Fork** = read historical steps from `.data.jsonl`, pass as `input.steps` to a new generator - **Zero injection** β€” the bundle doesn't import or receive anything from the engine ### Fork/Resume via ThreadInput When using the `createRoleModerator` helper, fork is **naturally handled**: ```typescript // The moderator receives ThreadContext with historical steps // It sees planner already ran β†’ routes to coder automatically const gen = workflow( { prompt: "fix bug #3", steps: [{ role: "planner", content: "...", meta: {} }] }, { threadId: "01KQXKW18CT8G75T53R8F4G7YG", maxRounds: 10 } ); // First yield will be coder's output, not planner's ``` No special replay logic needed β€” the moderator/role pattern inherently supports resuming from any snapshot, because moderator routing is a pure function of the accumulated steps. This follows the **Dependency Inversion Principle**: the engine depends on the generator protocol (a language primitive), not on a framework-specific `WorkflowDefinition`. Bundles remain pure functions with no coupling to `@uncaged/workflow`. ### Relationship to Role/Moderator Pattern The Role + Moderator pattern from Section 8 is one **implementation strategy** inside a bundle, not the bundle contract itself. A helper like `createRoleModerator(roles, moderator)` can produce the AsyncGenerator internally, but simple workflows can yield directly without any framework types. ### Constraints - Single `.esm.js` file - Named exports `run` (callable AsyncGenerator workflow) and `descriptor` (metadata object) - No default export - No dynamic `import()` - All static imports must be Node built-in modules only This guarantees the file is self-contained, and its **XXH64 hash** (encoded as Crockford Base32) serves as a globally unique version identifier. ### Role Descriptor (`export const descriptor`) The bundle **must** export a `descriptor` object describing roles for tooling/agent consumption. Shape: `{ description: string, roles: Record }` When you register a bundle via `uncaged-workflow add`, the engine imports the module, validates `descriptor`, and writes `{hash}.yaml` next to `{hash}.esm.js` under `bundles/` (same serialized shape as below): ```yaml description: "Workflow brief introduction" roles: planner: description: "Analyzes the issue and creates a plan" schema: type: object properties: plan: type: string files: type: array items: type: string coder: description: "Implements the plan" schema: type: object properties: diff: type: string ``` Execution uses `run` only; YAML is for tooling and introspection. ## 3. Storage Layout All data lives under `~/.uncaged/workflow/`: ``` ~/.uncaged/workflow/ β”œβ”€β”€ bundles/ # ESM bundles β”‚ β”œβ”€β”€ C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64 hash β”‚ └── C9NMV6V2TQT81.yaml # Role descriptor (from bundle export, at register time) β”œβ”€β”€ logs/ # Thread data, one folder per bundle hash β”‚ └── C9NMV6V2TQT81/ β”‚ β”œβ”€β”€ 01KQXKW18CT8G75T53R8F4G7YG.data.jsonl β”‚ └── 01KQXKW18CT8G75T53R8F4G7YG.info.jsonl └── workflow.yaml # Registry ``` **Not** a git repo. **Not** an npm package. Bundles are self-contained single files. ### ID Encoding All IDs use **Crockford Base32**: - Better readability than Base64 - Higher density than hex (shorter filenames) - ULID: 10 chars timestamp (high 2 bits zero-padded for future use) + 16 chars random ## 4. Registry (`workflow.yaml`) ```yaml workflows: solve-issue: hash: "C9NMV6V2TQT81" timestamp: 1714963200000 history: - hash: "A7BKR3M1NPQ40" timestamp: 1714876800000 - hash: "X2FGH8J4KLM56" timestamp: 1714790400000 ``` Type: ```typescript { workflows: Record } ``` No concurrency control or timeout settings in the registry β€” those belong to each workflow/role/adapter. ## 5. Thread JSONL Format ### `.data.jsonl` β€” Thread State **Line 1: Start record** ```jsonc { "name": "solve-issue", "hash": "C9NMV6V2TQT81", "threadId": "01KQXKW18CT8G75T53R8F4G7YG", "parameters": { "prompt": "Fix the login redirect bug in #3", "options": { "maxRounds": 5 } }, "timestamp": 1714963200000 } ``` **Line 2+: Role outputs** ```jsonc { "role": "planner", "content": "Plan: modify auth middleware...", "meta": { "plan": "...", "files": ["src/auth.ts"] }, "timestamp": 1714963201000 } ``` ### `.info.jsonl` β€” Debug Log ```jsonc { "tag": "4KNMR2PX", // 40-bit random, Crockford Base32 (8 chars) "content": "Loading workflow bundle...", "timestamp": 1714963200500 } ``` ## 6. Execution Model - **No daemon.** `uncaged-workflow run ` starts a worker process. - Same bundle's threads share one process (memory efficiency). - Process exits automatically when all threads complete. - Thread termination requires **IPC** within the process (not just kill PID). ## 7. CLI Requirements ### P1 (Must Have) | Command | Description | |---------|-------------| | `uncaged-workflow add [--types ]` | Register a compiled `.esm.js` bundle (descriptor extracted from `export const descriptor`) | | `uncaged-workflow list` | List registered workflows | | `uncaged-workflow show ` | Show workflow details | | `uncaged-workflow remove ` | Remove a workflow | | `uncaged-workflow run [--prompt] [--max-rounds]` | Start a thread | | `uncaged-workflow threads [name]` | List threads (optionally filter by workflow) | | `uncaged-workflow thread ` | Show thread state | | `uncaged-workflow thread rm ` | Delete a thread | | `uncaged-workflow ps` | List running threads | | `uncaged-workflow kill ` | Terminate a running thread (via IPC) | ### P2 (Should Have) | Command | Description | |---------|-------------| | `uncaged-workflow history ` | Show version history | | `uncaged-workflow rollback [hash]` | Switch to a previous version | | `uncaged-workflow pause ` | Pause a running thread | | `uncaged-workflow resume ` | Resume a paused thread | ### P3 (Nice to Have) | Command | Description | |---------|-------------| | `uncaged-workflow fork [--from-role ]` | Fork from a historical thread state | ## 8. Role/Moderator Pattern (Helper, Not Contract) The bundle contract is the AsyncGenerator from Section 2. The Role + Moderator pattern below is a **convenience helper** for the common case of multi-role workflows with a routing function. It lives in `@uncaged/workflow` as an optional utility. ### Helper Function ```typescript function createRoleModerator( def: { roles: { [K in keyof M & string]: Role }; moderator: Moderator } ): WorkflowFn; // returns (input: ThreadInput, options) => AsyncGenerator ``` Usage in a bundle: ```typescript import { createRoleModerator, END } from "@uncaged/workflow"; export const descriptor = { description: "Example multi-role workflow", roles: { planner: { description: "Plans work", schema: {} }, coder: { description: "Writes code", schema: {} }, }, }; export const run = createRoleModerator({ roles: { planner, coder }, moderator(ctx) { return ctx.steps.length === 0 ? "planner" : END; }, }); // Accepts ThreadInput β€” fork with pre-filled steps works automatically ``` ### Supporting Types ```typescript /** Sentinel values for automaton control flow. */ const START = "__start__" as const; const END = "__end__" as const; /** Maps role names β†’ their meta types. Single generic drives all inference. */ type RoleMeta = Record>; /** Typed output of a Role execution. */ type RoleResult = { content: string; meta: Meta }; /** Engine start frame: initial prompt + thread identity. */ type StartStep = { role: START; content: string; // the user prompt meta: { maxRounds: number; threadId: string }; timestamp: number; }; /** A completed role step in the thread. */ type RoleStep = { [K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number }; }[keyof M & string]; /** Thread-scoped context passed to roles and moderator. */ type ThreadContext = { threadId: string; start: StartStep; steps: RoleStep[]; }; /** * A Role β€” receives full thread context, returns typed content + meta. * Implementation can be an agent, LLM call, script, HTTP request, etc. */ type Role = (ctx: ThreadContext) => Promise>; /** * An Agent β€” raw string output interface for LLM/CLI adapters. * Structured meta is extracted by the role's extract layer. */ type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise; /** * The Moderator β€” a pure routing function. * Receives the full thread context (start + all prior steps). * On initial call, `steps` is empty. * Returns the next role name or END to terminate. */ type Moderator = (ctx: ThreadContext) => (keyof M & string) | END; /** Complete workflow definition as authored by users. */ type WorkflowDefinition = { name: string; roles: { [K in keyof M & string]: Role }; moderator: Moderator; }; ``` ### Execution Flow (when using createRoleModerator) ``` START (prompt) β†’ Moderator β†’ Role A β†’ Moderator β†’ Role B β†’ ... β†’ Moderator β†’ END ``` 1. Engine creates a `StartStep` with the user prompt and maxRounds 2. Moderator is called with `steps = []`, returns the first role name 3. Role executes, appends a `RoleStep` to the thread 4. Moderator is called again with updated steps, returns next role or END 5. Repeat until END or maxRounds reached ### Responsibilities | Component | Responsibility | Purity | |-----------|---------------|--------| | **Moderator** | Route to next role based on thread state | Pure function, no side effects | | **Role** | Execute a step (call LLM, run script, etc.) | Async, may have side effects | | **AgentFn** | Low-level LLM/CLI invocation adapter | Async, side effects | ### Key Constraints - Moderator is **synchronous and pure** β€” no I/O, no state mutation - Roles receive the **full thread context** (not just the last message) - Round count = `steps.length`; max rounds in `start.meta.maxRounds` - The `meta` field on each step is **typed per role** via the `RoleMeta` generic ## 9. Design Decisions & Rationale ### Why single-file ESM? - Hash = version. No ambiguity. - No dependency hell. Self-contained. - Simple to distribute, store, and verify. ### Why no daemon? - Unnecessary complexity for process-per-bundle model. - OS process management (systemd, etc.) handles restarts. - IPC within process handles thread lifecycle. ### Why Crockford Base32? - Case-insensitive, filesystem-safe. - No ambiguous characters (0/O, 1/I/L). - More compact than hex (13 chars for 64-bit vs 16). ### Why not control concurrency in registry? - Different workflows have different constraints. - Same workflow may allow cross-project concurrency but not intra-project. - Concurrency belongs at workflow/role/adapter level.