Files
united-workforce/docs/rfc-001-workflow-engine.md
T
xiaoju eda00d1c8e docs(rfc-001): bundle contract → AsyncGenerator, Role/Moderator → helper pattern
Breaking design change:
- Section 2: WorkflowFn is now an AsyncGenerator that yields RoleOutput
- Section 8: Role/Moderator demoted from contract to optional helper
- Engine controls the loop via generator protocol (DIP)
- Zero injection — bundles don't import engine types

小橘 <xiaoju@shazhou.work>
2026-05-06 05:18:56 +00:00

12 KiB

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 <subcommand>.

Monorepo uses bun workspace.

2. Workflow Physical Implementation

A Workflow is a single-file ESM module that default-exports an AsyncGenerator function:

/** What each yield produces — one role's output. */
type RoleOutput = {
  role: string;
  content: string;
  meta: Record<string, unknown>;
};

/** What the generator returns when done. */
type WorkflowResult = {
  returnCode: number;
  summary: string;
};

/** The bundle contract — an AsyncGenerator, not a Promise. */
type WorkflowFn = (
  prompt: string,
  options: { isDryRun: boolean; maxRounds: number }
) => AsyncGenerator<RoleOutput, WorkflowResult>;

Why AsyncGenerator?

The workflow yields each role output instead of writing to an injected writer or exporting a framework-specific shape:

// Example bundle — zero framework dependency
export default async function* (prompt, options) {
  const plan = await callLLM("plan: " + 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 = replay the first N yields from persisted .data.jsonl, then resume iteration
  • Zero injection — the bundle doesn't import or receive anything from the engine

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
  • 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 (Optional)

A YAML file alongside the bundle describes roles for tooling/agent consumption:

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

Format: { description: string, roles: Record<string, { description: string, schema: JSONSchema }> }

This file is not required for execution.

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 (optional)
├── 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)

workflows:
  solve-issue:
    hash: "C9NMV6V2TQT81"
    timestamp: 1714963200000
    history:
      - hash: "A7BKR3M1NPQ40"
        timestamp: 1714876800000
      - hash: "X2FGH8J4KLM56"
        timestamp: 1714790400000

Type:

{
  workflows: Record<string, {
    hash: string;           // Crockford Base32 of current XXH64
    timestamp: number;
    history: { hash: string; timestamp: number }[];
  }>
}

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

{
  "name": "solve-issue",
  "hash": "C9NMV6V2TQT81",
  "threadId": "01KQXKW18CT8G75T53R8F4G7YG",
  "parameters": {
    "prompt": "Fix the login redirect bug in #3",
    "options": {
      "isDryRun": false,
      "maxRounds": 5
    }
  },
  "timestamp": 1714963200000
}

Line 2+: Role outputs

{
  "role": "planner",
  "content": "Plan: modify auth middleware...",
  "meta": { "plan": "...", "files": ["src/auth.ts"] },
  "timestamp": 1714963201000
}

.info.jsonl — Debug Log

{
  "tag": "4KNMR2PX",       // 40-bit random, Crockford Base32 (8 chars)
  "content": "Loading workflow bundle...",
  "timestamp": 1714963200500
}

6. Execution Model

  • No daemon. uncaged-workflow run <name> 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 <name> <file> Register a workflow bundle
uncaged-workflow list List registered workflows
uncaged-workflow show <name> Show workflow details
uncaged-workflow remove <name> Remove a workflow
uncaged-workflow run <name> [--prompt] [--dry-run] [--max-rounds] Start a thread
uncaged-workflow threads [name] List threads (optionally filter by workflow)
uncaged-workflow thread <id> Show thread state
uncaged-workflow thread rm <id> Delete a thread
uncaged-workflow ps List running threads
uncaged-workflow kill <thread-id> Terminate a running thread (via IPC)

P2 (Should Have)

Command Description
uncaged-workflow history <name> Show version history
uncaged-workflow rollback <name> [hash] Switch to a previous version
uncaged-workflow pause <thread-id> Pause a running thread
uncaged-workflow resume <thread-id> Resume a paused thread

P3 (Nice to Have)

Command Description
uncaged-workflow fork <thread-id> [--from-role <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

function createRoleModerator<M extends RoleMeta>(
  def: { roles: { [K in keyof M & string]: Role<M[K]> }; moderator: Moderator<M> }
): (prompt: string, options: { isDryRun: boolean; maxRounds: number }) => AsyncGenerator<RoleOutput, WorkflowResult>;

Usage in a bundle:

import { createRoleModerator } from "@uncaged/workflow";

export default createRoleModerator({
  roles: { planner, coder },
  moderator(ctx) { return ctx.steps.length === 0 ? "planner" : END; },
});

Supporting Types

/** 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<string, Record<string, unknown>>;

/** Typed output of a Role execution. */
type RoleResult<Meta> = { 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<M extends RoleMeta> = {
  [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<M extends RoleMeta = RoleMeta> = {
  threadId: string;
  start: StartStep;
  steps: RoleStep<M>[];
};

/**
 * A Role — receives full thread context, returns typed content + meta.
 * Implementation can be an agent, LLM call, script, HTTP request, etc.
 */
type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>;

/**
 * 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<string>;

/**
 * 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<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END;

/** Complete workflow definition as authored by users. */
type WorkflowDefinition<M extends RoleMeta> = {
  name: string;
  roles: { [K in keyof M & string]: Role<M[K]> };
  moderator: Moderator<M>;
};

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.