Compare commits

...

79 Commits

Author SHA1 Message Date
xiaoju c15f58bdeb docs(skill): add authoring pitfalls to skill author topic
Add ModeratorTable syntax, AdapterFn/AdapterBinding types, lazy init
pattern, bundle import restrictions, and descriptor requirements.

Knowledge from smoke test discoveries — these are the most common
mistakes when writing workflow bundles.

小橘 <xiaoju@shazhou.work>
2026-05-13 03:57:49 +00:00
xiaoju f0d1bb9ae8 chore: bump all to 0.3.11
小橘 🍊
2026-05-13 03:28:33 +00:00
xiaoju 04cfd33f99 chore: bump all to 0.3.10 (regenerate lockfile)
小橘 🍊
2026-05-13 03:27:05 +00:00
xiaoju a8c00f169b chore: bump all packages to 0.3.9 (fix workspace:* dep resolution)
小橘 🍊
2026-05-13 03:25:50 +00:00
xiaoju c4d34530e8 chore: bump cli-workflow 0.3.8 (fix gateway dep resolution)
小橘 🍊
2026-05-13 03:23:09 +00:00
xiaoju 90a410c00a chore: bump cli-workflow to 0.3.7 (fix gateway dep version)
小橘 🍊
2026-05-13 03:21:34 +00:00
xiaoju 6276ca5a4a chore: publish workflow-gateway (remove private flag)
小橘 🍊
2026-05-13 03:20:33 +00:00
xiaoju 8e63f99eb6 chore: bump all public packages to 0.3.6
小橘 🍊
2026-05-13 03:18:25 +00:00
xiaomo 9ca70bbb69 Merge pull request 'feat: minimal tool set for workflow-agent-react (#222 Phase 3)' (#229) from feat/222-tools-smoke-test-phase3 into main 2026-05-13 03:16:37 +00:00
xiaomo ed1f38c7da Merge pull request 'refactor(dashboard): side-by-side graph + cards layout' (#215) from refactor/thread-detail-side-by-side-layout into main 2026-05-13 03:06:35 +00:00
xiaomo 1664d68b50 Merge pull request 'feat: WS request proxy — Phase 2 (#210)' (#214) from feat/210-ws-gateway-phase2 into main 2026-05-13 03:06:29 +00:00
xingyue 1871ef31b4 refactor(dashboard): replace vertical layout with side-by-side graph+cards
Change thread-detail from vertical (graph on top, cards below) to a
side-by-side layout:
- Left panel (280px, sticky): workflow graph, always visible
- Right panel (flex-1, scrollable): record cards
- Remove collapsible GraphPanel wrapper
- Graph acts as navigation (click node → scroll to card)

Refs: workflow thread 06F1NX4C9ET6HPXJAH7CWWF8MR
2026-05-13 11:05:03 +08:00
xingyue ec3c97b200 feat: WS request proxy — Gateway proxies HTTP via WebSocket (#210 Phase 2)
- Add ws-protocol.ts with WsRequest/WsResponse types + parsers
- AgentSocket DO: proxy POST handler, pending request map, 30s timeout
- /api/agents/:agent/* routes through DO WS when connected, falls back to HTTP
- ws-client handles incoming WsRequest, fetches local serve, returns WsResponse
- startGatewayWsClient accepts localPort for request handling

Testing: #213
2026-05-13 11:05:03 +08:00
xingyue 18e3dc7603 feat: WebSocket reverse-connection gateway Phase 1 (#210)
- Add AgentSocket Durable Object (holds one WS per agent name)
- Add /ws/connect route with GATEWAY_SECRET auth
- Add ws-client.ts with auto-reconnect (exponential backoff 1s-30s)
- serve defaults to WS mode (no cloudflared needed)
- Keep --tunnel-url and --no-tunnel as fallback options
- Endpoints list merges KV heartbeat + DO WebSocket status

Testing: #211
2026-05-13 11:05:03 +08:00
xiaoju fc229cac79 test: add tool handler unit tests (#222) 2026-05-13 02:57:47 +00:00
xiaoju ec555b43d1 feat: add minimal tool set (read/write/patch/shell) to workflow-agent-react (#222) 2026-05-13 02:57:47 +00:00
xiaomo c8de86d7c9 Merge pull request 'feat: workflow-agent-react + wrapAgentAsAdapter shared + childThread support (#222 Phase 2)' (#226) from feat/222-react-adapter-phase2 into main 2026-05-13 02:51:07 +00:00
xiaoju bd110b76e1 chore: remove accidental self-referencing symlinks
小橘 🍊
2026-05-13 02:44:24 +00:00
xiaoju dc10ccceaa test: add react adapter unit tests (#222)
小橘 🍊
2026-05-13 02:40:22 +00:00
xiaoju c040a90a8f feat: add @uncaged/workflow-agent-react package (#222) 2026-05-13 02:38:38 +00:00
xiaoju ec4599a230 refactor: extract wrapAgentAsAdapter to util-agent, support childThread in RoleFn (#222) 2026-05-13 02:37:32 +00:00
xiaomo 1f4bd3f431 Merge pull request 'feat(protocol): AdapterFn replaces AgentBinding in createWorkflow (#222 Phase 1)' (#224) from feat/222-adapter-fn-phase1 into main 2026-05-13 02:30:29 +00:00
xiaoju bebf4aad45 feat(protocol): add AdapterFn/RoleFn/AdapterBinding, refactor createWorkflow to use AdapterBinding (#222)
- Add RoleFn<T>, AdapterFn, AdapterBinding types to workflow-protocol
- Mark AgentFn, AgentFnResult, AgentBinding as @deprecated
- Refactor createWorkflow to accept AdapterBinding instead of AgentBinding
- Adapter returns typed meta directly — no more extract call in workflow loop
- Add buildThreadInput (ThreadContext-based), keep buildAgentPrompt as deprecated wrapper
- Update template bundle-entries to wrap AgentFn as AdapterFn
- Update solve-issue tests to use AdapterFn directly
2026-05-13 02:27:36 +00:00
xiaoju 11ba185fef docs: RFC v3 — react adapter as thin wrapper over reactor
小橘 🍊
2026-05-13 02:19:12 +00:00
xiaoju 730340d123 docs: RFC v2 — AdapterFn replaces AgentFn, schema-aware resolve
小橘 🍊
2026-05-13 02:15:21 +00:00
xiaoju c848216396 docs: RFC for workflow-agent-react package
小橘 🍊
2026-05-13 01:55:14 +00:00
xingyue 2698e0a6cb fix(setup): add GLM international endpoint (api.z.ai) 2026-05-13 09:52:07 +08:00
xingyue 47f2b1a128 fix(setup): address code review issues (#221)
- Fix resolve variable shadowing in promptSecret (rename to fulfill)
- Fix readline leak on invalid choice (close before returning err)
- Remove Anthropic/Gemini from presets (not OpenAI-compatible)
- Fix GLM URL: api.z.ai → open.bigmodel.cn
- Restore terminal raw mode before process.exit on Ctrl+C
- Add debug logging to fetchAvailableModels failures
- Add comment explaining DashScope-specific model filter patterns
- Move PresetProvider and CmdSetupSuccess types to types.ts per convention
2026-05-13 09:43:57 +08:00
xingyue 0c02cb7574 chore: publish v0.3.5
小橘 <xiaoju@shazhou.work>
2026-05-13 09:34:41 +08:00
xingyue 320810ec25 fix(cli-workflow): workspace path accepts relative/absolute paths with retry
- cmdInitWorkspace now resolves full paths via resolve() instead of
  requiring a single segment name
- mkdir uses recursive: true for nested paths (e.g. ./a/b/workflows)
- Setup interactive prompt retries on existing directory instead of exiting
- Update tests: nested paths are now valid, add accepts-nested-path test
2026-05-13 09:30:41 +08:00
xingyue 91f585c534 feat(cli-workflow): numbered model selection in setup
- Show available models with numbered labels in multi-column layout
- User can pick by number or type model name directly
- Print selected model with arrow confirmation
2026-05-13 09:25:00 +08:00
xingyue 299ff126d9 feat(cli-workflow): preset provider selection in setup
- Add providers.yaml with 18 preset providers (international + China + local)
- Add preset-providers.ts to load and cache YAML presets
- Refactor interactive setup to show numbered provider list
- Only prompt for manual name/URL when choosing Custom
- YAML-driven: add new providers without code changes
2026-05-13 09:19:30 +08:00
xingyue 931eb81458 fix(setup): default workspace to ./workflows when left empty
Enter = use default ./workflows. Type 'skip' to skip.
2026-05-12 22:32:01 +08:00
xingyue c604d1f600 fix(setup): simplify model prompt — just ask for model name
Provider is already known from the first step, so prompt simply
asks 'Default model:' and auto-prepends provider/ prefix.
2026-05-12 22:28:35 +08:00
xingyue 20bcc65f61 fix(setup): auto-prefix provider on model input
Users can now type bare model names (e.g. 'qwen-plus') or paste
model IDs with vendor prefixes (e.g. 'MiniMax/MiniMax-M2.7') —
the provider prefix is normalised automatically.
2026-05-12 22:27:07 +08:00
xingyue f5612ef1b5 fix(setup): filter non-chat models and display in multi-column layout
Filter out speech/embed/image/video/audio/tts/asr/ocr/rerank models
from the /models listing. Display remaining models in a responsive
multi-column grid that adapts to terminal width.
2026-05-12 22:09:13 +08:00
xingyue a92deeaf3f fix(setup): mask each character when pasting API key
Raw mode receives pasted text as a single chunk. Iterate
per-character so every char gets a '*'. Also fix backspace
to erase the visual '*' from the terminal.
2026-05-12 22:03:48 +08:00
xingyue 1e936cf04a fix: improve setup interactive UX
1. Mask API key input with * characters (raw mode)
2. Fetch and list available models from provider /models endpoint
3. Workspace prompt: fill path directly (default skip), not y/n
4. Add .gitkeep to workflows/ in init workspace scaffold
2026-05-12 21:44:21 +08:00
xingyue ea16057803 fix: improve setup interactive prompts with context and examples
- Add intro line explaining what's being configured
- Provider: explain it's a label for the LLM service
- Base URL: explain it's OpenAI-compatible, show examples
- API key: clarify it's for this provider
- Default model: show format with dynamic provider name
- Workspace prompt: clearer wording
2026-05-12 20:52:42 +08:00
xingyue 4493fd8979 chore: publish v0.3.4
小橘 <xiaoju@shazhou.work>
2026-05-12 20:35:02 +08:00
xiaomo cc1ee8d5e3 Merge pull request 'chore: address #219 review comments' (#220) from fix/219-review-followup into main 2026-05-12 12:29:44 +00:00
xingyue 0ad5c85f5a chore: address #219 review comments
- Add comment explaining biome.json scripts/bundle.ts exclusion
- Add note about readline API key visibility limitation
2026-05-12 20:27:27 +08:00
xiaomo d02d410dcd Merge pull request 'feat: setup command + workspace build scripts (#216)' (#219) from feat/216-setup-and-build-scripts into main 2026-05-12 12:24:43 +00:00
xingyue cdf3c95622 feat: add setup command for provider/model config (#216 Phase 2)
New command: uncaged-workflow setup

CLI mode (agent-friendly):
  uncaged-workflow setup \
    --provider <name> --base-url <url> --api-key <key> \
    --default-model <provider/model> [--init-workspace <name>]

Interactive mode: prompts for each value when no flags given.

- Reads/writes workflow.yaml config section
- Idempotent: updates provider without losing workflows
- Sets maxDepth=3, supervisorInterval=3 on fresh config
- Optional --init-workspace creates workspace in cwd

Testing: #218
Ref: #216
2026-05-12 20:18:23 +08:00
xingyue a7fea10383 feat: init workspace generates bundle script (#216 Phase 1)
- Add scripts.bundle to generated package.json
- Generate scripts/bundle.ts that scans workflows/*-entry.ts,
  uses Bun.build() to produce dist/*.esm.js + dist/*.d.ts
- External: @uncaged/workflow-* packages (per bundle contract)
- Add tests for new scaffold files

Testing: #217
Ref: #216
2026-05-12 20:09:41 +08:00
xingyue 3846dc12a9 chore: bump all public packages to 0.3.3 2026-05-12 12:58:17 +08:00
xingyue c5fd84432f fix(agent): defer config validation to call time
Bundle top-level code runs during `workflow add` (descriptor extraction),
but agent config env vars (e.g. WORKFLOW_HERMES_COMMAND) are only available
at `workflow run` time. Deferring validation prevents premature throws.
2026-05-12 12:58:06 +08:00
xingyue 4c4dabb7a3 chore: bump all public packages to 0.3.2 2026-05-12 12:55:21 +08:00
xingyue 1b62cec0a2 feat(agent): require absolute path for command in hermes/cursor agent configs
BREAKING: HermesAgentConfig.command and CursorAgentConfig.command are now
required string fields (absolute path to CLI binary). Validation rejects
non-absolute paths at construction time.

- Eliminates PATH resolution ambiguity in spawned worker processes
- spawnCli: explicit env: process.env for clarity
- bundle-entry: WORKFLOW_CURSOR_COMMAND is now required
- Updated tests for both agents
2026-05-12 12:52:48 +08:00
xingyue ecc348f182 feat(agent): add command config to hermes/cursor agents + explicit env inheritance
- HermesAgentConfig.command: override hermes CLI path (default: "hermes")
- CursorAgentConfig.command: override cursor-agent CLI path (default: "cursor-agent")
- spawnCli: explicit env: process.env for clarity and future extensibility
- bundle-entry: read WORKFLOW_CURSOR_COMMAND from env
2026-05-12 12:49:28 +08:00
xingyue 41209f1ef8 docs: add end-to-end development flow to CLAUDE.md 2026-05-12 11:38:03 +08:00
xingyue 58a4aefcc4 refactor(publish): auto topological sort instead of hardcoded order
Kahn algorithm reads workspace:* deps from all package.json files
and publishes leaf-first. No manual maintenance when adding packages.
2026-05-12 11:36:29 +08:00
xingyue bbb79f821e feat(init): generate bunfig.toml with Gitea scoped registry + fix versions
- init workspace now generates bunfig.toml pointing @uncaged scope to Gitea
- Fix template versions: "*" → "^0.3.1" (packages are now published)
2026-05-12 11:31:41 +08:00
xingyue 05fbd4f5b5 feat(publish): add Gitea npm registry publish script + docs
- scripts/publish-all.sh: bun pm pack (resolves workspace:*) + npm publish
- All 14 public @uncaged/* packages published to git.shazhou.work
- CLAUDE.md: document Gitea registry, bunfig.toml scoped registry, publish workflow
- bun link docs demoted to alternative for un-published local changes
2026-05-12 11:30:52 +08:00
xingyue 7e7331eb2d chore: warn against bun install after link --consume 2026-05-12 11:10:04 +08:00
xingyue 0fbbf37548 chore(cli): add bun run link scripts + fix init template versions
- Add link/link:consume/link:unlink scripts to root package.json
- Document cross-repo bun link workflow in CLAUDE.md
- Fix hardcoded @uncaged/workflow-runtime "^0.1.0" → "*" in
  init workspace and init template scaffolds (actual version is 0.3.x)
2026-05-12 11:03:12 +08:00
xingyue 2af39463de scripts: link-all.sh support register/consume/unlink modes 2026-05-12 10:59:12 +08:00
xingyue 5f2458238f scripts: add link-all.sh for local @uncaged/* package linking 2026-05-12 10:56:31 +08:00
xingyue aadec0b96c feat: WorkflowList expandable cards with static graph (#198)
- Workflow cards click to expand/collapse
- Lazy-load descriptor on first expand
- Static WorkflowGraph (all nodes default state, no highlighting)
- Show description, version count, hash
- Fix WorkflowSummary type to match actual API response
2026-05-12 10:53:49 +08:00
xiaomo 1c68ce6217 Merge pull request 'feat: Phase 3 — agent observability for Merkle call stack' (#203) from feat/197-agent-observability into main 2026-05-12 02:34:47 +00:00
xingyue 7265603b55 feat: click graph node to scroll and highlight RecordCard (#198 Phase 3) 2026-05-12 10:34:06 +08:00
xiaoju 74cea09ac0 fix: bundle validator accepts Identifier init and wildcard @uncaged/workflow-* imports
- bindingInitializerIsCallable: accept Identifier (e.g. var run = wf)
- import allowlist: startsWith('@uncaged/workflow') instead of exact match list

小橘 🍊(NEKO Team)
2026-05-12 02:33:28 +00:00
xingyue b1e66fa7a4 fix: use async/await instead of .then() in getWorkflowDescriptor 2026-05-12 10:29:50 +08:00
xiaomo 81a7a8c7c1 Merge pull request 'feat: Dashboard workflow graph visualization (React Flow)' (#204) from feat/198-dashboard-workflow-graph into main 2026-05-12 02:28:40 +00:00
xingyue 9cb7d68abe feat: Dashboard workflow graph visualization with React Flow (#198)
Phase 1: API + static graph rendering

Backend:
- GET /workflows/:name now returns descriptor (with graph) from bundle YAML
- Graceful fallback to null if YAML missing/invalid

Frontend:
- New workflow-graph/ component module (7 files)
- React Flow + dagre auto-layout (TB direction)
- Custom nodes: RoleNode (rounded rect) + TerminalNode (circle for START/END)
- Custom edges: dashed for FALLBACK, solid with label for conditions
- Self-loop edges supported (e.g. coder → coder)
- Node states: default/completed/active with color-coded borders
- Active node pulse animation
- Collapsible graph panel (300px) above thread records
- Dark theme using existing CSS variables

Integration:
- ThreadDetail extracts workflow name → fetches descriptor → computes node states → renders graph
- Node states derived from ThreadRecord[] (completed/active/default)
2026-05-12 10:27:07 +08:00
xiaoju 98122b446d feat: Phase 3 — agent observability for Merkle call stack
- StartStep gains parentState: string | null (from StartNodePayload)
- buildAgentPrompt injects Parent Context section when parentState is set
- CLI thread show outputs parentState (top-level) and childThread (per step)
- 2 new prompt tests + thread show assertion updates

Refs #197, #194

小橘 🍊(NEKO Team)
2026-05-12 02:23:15 +00:00
xiaoju 4a31cf9d63 fix: workflowAsAgent error paths return AgentFnResult instead of plain string
Nit from PR #202 review — all error returns now use { output, childThread: null }
for type consistency with the success path.

小橘 🍊(NEKO Team)
2026-05-12 02:15:06 +00:00
xingyue 2c26be6ec6 docs: update graph visualization RFC — Phase 0 done (#198) 2026-05-12 10:13:39 +08:00
xiaomo f723daa014 Merge pull request 'feat(#194): Phase 2 — Engine layer Merkle call stack' (#202) from feat/194-merkle-call-stack-phase2 into main 2026-05-12 02:11:24 +00:00
xiaoju 1e9900bed3 feat(#194): Phase 2 — Engine layer Merkle call stack wiring
- Protocol: AgentFnResult = string | { output, childThread }, RoleOutput.childThread,
  ThreadContext.bundleHash for parent state lookup
- Runtime: create-workflow normalizes AgentFnResult, propagates childThread in RoleOutput
- Engine: ExecuteThreadOptions.parentStateHash, appendStateForStep writes childThread,
  putStartNode uses parentStateHash
- workflowAsAgent: reads parent head state from threads.json, passes parentStateHash
  to child, returns { output, childThread: rootHash }
- Integration test: 4 cases verifying bidirectional Merkle links (306 lines)

Phase 2 of #194 (Merkle Call Stack). Closes #196.

小橘 <xiaoju@shazhou.work>
2026-05-12 02:10:06 +00:00
xiaomo aebff8b906 Merge pull request 'refactor: replace Moderator function with ModeratorTable in WorkflowDefinition' (#201) from refactor/200-moderator-table into main 2026-05-12 02:06:03 +00:00
xingyue db45089922 refactor: replace Moderator function with ModeratorTable in WorkflowDefinition (#200)
- WorkflowDefinition.moderator → WorkflowDefinition.table (ModeratorTable)
- Moderator type + tableToModerator no longer exported from protocol/runtime
- tableToModerator internalized in workflow-execute engine layer
- WorkflowDescriptor gains graph: WorkflowGraph (auto-extracted from table)
- buildDescriptor extracts serializable graph edges from ModeratorTable
- validateWorkflowDescriptor validates graph structure
- All templates (develop, solve-issue) export table directly
- CLI init scaffold updated to use ModeratorTable
- 99 tests pass, 0 failures
2026-05-12 10:01:30 +08:00
xiaomo 9c1b018ffa Merge pull request 'feat(#194): Phase 1 — Merkle Call Stack protocol + CAS layer' (#199) from feat/194-merkle-call-stack-phase1 into main 2026-05-12 01:50:05 +00:00
xiaoju a98431a12a feat(#194): Phase 1 — parentState / childThread in CAS nodes
- Protocol: StartNodePayload.parentState, StateNodePayload.childThread
- CAS: putStartNode refs include parentState, collectRefs includes childThread
- Parsing: legacy nodes without new fields default to null
- Engine + fork: all callers pass parentState: null / childThread: null
- Tests: 8 new cases for refs, parsing, collect-refs (+208 lines)

Phase 1 of #194 (Merkle Call Stack). Closes #195.

小橘 <xiaoju@shazhou.work>
2026-05-12 01:42:10 +00:00
xingyue 0fe17b0fb2 docs: workflow graph visualization design plan (#198) 2026-05-12 09:38:58 +08:00
xiaoju e37dbc3f35 wip: Phase 1 protocol + CAS types for Merkle call stack
小橘 <xiaoju@shazhou.work>
2026-05-12 01:35:45 +00:00
xiaoju 82d9abf260 rfc: Merkle Call Stack — cross-thread DAG linking
Design doc for parent-child workflow Merkle linking:
- StartNodePayload.parentState: child → parent head state at spawn time
- StateNodePayload.childThread: parent → child final state hash
- Both also in refs[] for GC reachability
- 4-phase implementation plan

小橘 <xiaoju@shazhou.work>
2026-05-12 01:29:38 +00:00
xiaoju 50aec2d0cf fix: use unique log tags per call site in extract-workspace
W8KN3QYT — extraction failed
H4PM7RXV — non-absolute path
V3KM8QWP — success

小橘 <xiaoju@shazhou.work>
2026-05-12 00:58:37 +00:00
xiaomo e979a55f8a Merge pull request 'feat: cursor agent auto-extracts workspace from context' (#193) from feat/cursor-agent-workspace-extract into main 2026-05-12 00:57:33 +00:00
132 changed files with 5270 additions and 457 deletions
+58
View File
@@ -245,6 +245,64 @@ bun run format # biome format --write
bun test # run tests
```
### Publishing to Gitea npm Registry
All public `@uncaged/*` packages are published to the Gitea npm registry at `git.shazhou.work`. Workflow workspaces consume packages from this registry via `bunfig.toml`.
```bash
# Publish all packages (bun pm pack resolves workspace:* → actual versions)
bun run publish:gitea
# Dry run — see what would be published
bun run publish:gitea:dry
```
Prerequisites: `.npmrc` in monorepo root with Gitea auth token (`//git.shazhou.work/api/packages/shazhou/npm/:_authToken=<token>`).
### Workflow Workspace Setup
External workflow repos (e.g. `xingyue-workflows`) use the Gitea registry for `@uncaged/*` packages. Add a `bunfig.toml`:
```toml
[install.scopes]
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
```
Then `bun install` resolves `@uncaged/*` from Gitea, all other packages from npmjs.
### Cross-repo Development (bun link)
Alternative for development against un-published local changes:
```bash
bun run link # Register all packages (from monorepo root)
bun run link:consume # Link into CWD's project (⚠️ don't bun install after)
bun run link:unlink # Restore original deps
```
### End-to-end: Monorepo → Registry → Workspace → Bundle
The recommended development flow for building workflows:
```
workflow/ (monorepo) — engine, runtime, templates, agents
│ bun run publish:gitea — auto topo-sort, bun pm pack → npm publish
git.shazhou.work npm registry — @uncaged/* scoped packages
│ bun install — via bunfig.toml scoped registry
my-workflows/ (workspace) — bunfig.toml + normal package.json
│ bun run build:develop — bun build → single .esm.js
uncaged-workflow workflow add — register bundle locally
uncaged-workflow run — execute workflow
```
1. **Monorepo changes**`bun run publish:gitea` (packages auto-discovered from `packages/*/`, topologically sorted, `workspace:*` resolved to real versions)
2. **Workspace**`bun install` fetches latest from Gitea, `bun install` is safe to run anytime
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
4. **Register & Run**`uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
## Commit Convention
```
+197
View File
@@ -0,0 +1,197 @@
# RFC: Merkle Call Stack — Cross-Thread DAG Linking
**Author:** 小橘 🍊(NEKO Team)
**Date:** 2026-05-11
**Status:** Draft
## Problem
`workflowAsAgent` 在父 workflow 中 spawn 子 workflow 时,父子 thread 之间没有任何 Merkle 链接:
1. **子 thread 不知道自己从哪来** — start node 只有 prompt hash,无法追溯父 thread 的上下文(preparer 分析出的 repoPath、conventions 等)
2. **父 thread 不知道子 thread 在哪** — developer role 的 state node 里只有 agent 返回的文本,child thread root hash 埋在字符串里,不是结构化 ref
3. **上下文传递靠序列化到 prompt** — 父 workflow 前置 role 的产出只能通过拼字符串传给子 workflow,丢失了 Merkle DAG 的可遍历性
## Proposal
在 CAS 节点中建立父子 thread 之间的 **双向 Merkle 链接**,形成调用栈结构。
### 新增字段
#### StartNodePayload(子 → 父)
```typescript
type StartNodePayload = {
name: string;
hash: string;
depth: number;
parentState: string | null; // NEW: 父 thread 调用时的 head state hash
};
```
`parentState` 指向子 workflow 被 spawn 时,父 thread 的最后一个 state node hash。这是"调用发生时的调用栈帧"。
#### StateNodePayload(父 → 子)
```typescript
type StateNodePayload = {
role: string;
meta: Record<string, unknown>;
start: string;
content: string;
ancestors: string[];
compact: string | null;
timestamp: number;
childThread: string | null; // NEW: 子 thread 最终 state hash(执行结果)
};
```
`childThread` 指向子 thread 完成后的**最终 state hash**(不是 start)——语义上是"函数返回值",从这里沿 ancestors 可回溯子 thread 的完整执行历史。
### refs 同步
新增的 hash 也必须放进 `refs[]`
- `StartNode.refs`: `[promptHash, parentState]`(parentState 非 null 时)
- `StateNode.refs`: `[...existingRefs, childThread]`(childThread 非 null 时)
原因:GC 的 `findReachableHashes` 只走 `refs`,不解析 payload 字段。字段提供语义,refs 保证可达性。
### 具体 DAG 结构
`solve-issue`(fix #191)为例,developer role 委托给 `develop` 子 workflow:
```
父 thread: solve-issue
═══════════════════════════════════════════════════════════
content("fix #191")
hash: ABCD1234
start(solve-issue)
hash: START001
payload: { name: "solve-issue", hash: BUNDLE_SI, depth: 0, parentState: null }
refs: [ABCD1234]
state(preparer)
hash: STATE_P1
payload: { role: "preparer", meta: { repoPath: "...", ... }, childThread: null, ... }
refs: [PREP_CONTENT]
state(developer) ──────── 父→子 ────────
hash: STATE_D1 │
payload: { role: "developer", meta: { ... }, childThread: ★CSTATE_END, ... }
refs: [DEV_CONTENT, ★CSTATE_END] │
state(submitter) │
hash: STATE_S1 │
payload: { role: "submitter", ..., childThread: null } │
子 thread: develop │
═══════════════════════════════════════════════════════════ │
content("fix #191") (CAS 去重,可能同 ABCD1234) │
hash: CPROMPT1 │
──────── 子→父 ──────── │
start(develop) │ │
hash: CHILD_START │ │
payload: { name: "develop", hash: BUNDLE_DEV, depth: 1, │
parentState: ★STATE_P1 } │ │
refs: [CPROMPT1, ★STATE_P1] │ │
│ │
state(planner) │ │
hash: CSTATE_1 │ │
... │ │
│ │
state(coder) │ │
hash: CSTATE_2 │ │
... │ │
│ │
state(reviewer) → state(tester) → state(committer) │
│ │
hash: ★CSTATE_END ◄─────────────────┼─────────────────────────┘
```
### 遍历路径
**子 thread agent 获取父上下文(上行):**
```
当前 step → start(CHILD_START)
→ refs[1] = STATE_P1(父 preparer 的 state)
→ payload.meta.repoPath = "/home/.../workflow"
→ refs → PREP_CONTENT(完整 preparer 输出)
→ payload.start = START001(父的 start node)
→ refs[0] = ABCD1234(原始 prompt)
```
**从父 thread 追踪子 thread 执行(下行):**
```
STATE_D1(父 developer state)
→ payload.childThread = CSTATE_END
→ 子 thread 最终 state
→ 沿 ancestors 回溯:committer → tester → reviewer → coder → planner
→ payload.start = CHILD_START(子 thread 入口)
```
**完整调用栈还原:**
```
任意节点 → 沿 start 找到所属 thread 的 StartNode
→ parentState 非 null?沿 parentState 进入父 thread
→ 递归直到 parentState = null(顶层 workflow)
```
## Implementation Plan
### Phase 1: Protocol + CAS 层
1. `workflow-protocol/src/cas-types.ts``StartNodePayload``parentState: string | null``StateNodePayload``childThread: string | null`
2. `workflow-cas/src/nodes.ts``putStartNode` 接受可选 `parentStateHash`,放入 refs;`putStateNode` 接受可选 `childThreadHash`,放入 refs
3. `workflow-cas/src/nodes.ts` — 解析逻辑兼容新字段(缺失时视为 null)
### Phase 2: Engine 层
4. `workflow-execute/src/engine/engine.ts``executeThread` 接受 `parentStateHash: string | null`,传给 `putStartNode`
5. `workflow-execute/src/workflow-as-agent.ts` — spawn 子 thread 时传入父 thread 当前 head state hash 作为 `parentStateHash`;子 thread 完成后返回最终 state hash
6. Engine 写 developer role 的 state node 时,把子 thread 最终 hash 写入 `childThread` 字段
### Phase 3: Agent 可观测性
7. Agent prompt 构建(`buildAgentPrompt`)— 当 start node 有 `parentState` 时,提示 agent 可通过 `cas get` 遍历父上下文
8. CLI `thread show` — 显示 parentState / childThread 链接关系
### Phase 4: 验证
9. 已有测试适配新字段(向后兼容,旧节点 parentState/childThread 为 null)
10. 新增集成测试:workflowAsAgent 场景下验证双向链接正确写入
## Design Decisions
### 为什么 childThread 指向 end 而不是 start?
- 语义是"函数返回值"——父 role 执行完才产出 state,此时子 thread 已跑完
- 从 end 沿 ancestors 可回溯到 start;反过来 start 写入时子 thread 还没跑完,无法知道 end
### 为什么 parentState 指向 state 而不是 start?
- 指向父 thread 调用点的**前一个 state**(即调用发生时的 head)
- 这是子 workflow 能看到的父上下文的"切面"——所有已完成的前置 role 都可达
- 如果是第一个 role 就 spawn 子 workflow(没有前置 state),parentState 指向父的 start node
### 为什么同时放字段和 refs?
- `refs[]` 服务于 GC(`findReachableHashes` 只遍历 refs)和通用 DAG 遍历
- `payload.parentState` / `payload.childThread` 服务于语义读取(明确知道哪个 ref 是什么)
- 不改 GC 逻辑,只加字段,GC 自然正确
### 向后兼容
- 新字段默认 `null`,旧节点解析时缺失字段视为 `null`
- 不影响已有 thread 的遍历和 GC
- `depth` 可通过沿 parentState 链上溯来交叉验证(数据自证)
## Open Questions
1. **多子 thread** — 如果一个 role 需要 spawn 多个子 workflow(目前不存在这个场景),`childThread` 应该改成 `childThreads: string[]` 还是保持单个?
2. **Agent prompt 注入深度** — 子 workflow 的 agent 应该自动遍历多少层父上下文?全部还是限制深度?
3. **CLI 展示**`thread show` 要不要递归展示整个调用栈,还是只显示直接链接?
@@ -0,0 +1,224 @@
# Dashboard Workflow Graph Visualization
**Issue**: #198
**Status**: In Progress
**Author**: xingyue
## Overview
在 Dashboard 的 ThreadDetail 页面中嵌入一个交互式流程图,将 workflow 的 `ModeratorTable` 可视化为有向图。用户可以一眼看到角色流转结构和当前执行进度。
## 数据层(✅ 已完成 — PR #201)
### WorkflowGraph 类型
`WorkflowDefinition.moderator`(函数)已替换为 `WorkflowDefinition.table`(声明式 `ModeratorTable`),`buildDescriptor` 自动从 table 提取 graph:
```ts
type WorkflowGraphEdge = {
from: string; // source role 或 "__start__"
to: string; // target role 或 "__end__"
condition: string; // condition.name 或 "FALLBACK"
conditionDescription: string | null;
};
type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph; // 必填,新 bundle 自动生成
};
```
### 数据流
```
ModeratorTable (WorkflowDefinition.table)
→ buildDescriptor() 自动提取 graph
→ descriptor.yaml 持久化(hash.yaml)
→ CLI serve /workflows/:name API 返回 descriptor
→ Dashboard 前端拿到 graph
```
### 剩余数据层工作
**serve API 需要返回 descriptor**:当前 `GET /workflows/:name` 只返回 registry entry(hash + timestamp),不含 descriptor。需要从 `bundles/{hash}.yaml` 读取 descriptor 并返回给前端。
方案:在 `routes-workflow.ts``GET /workflows/:name` 响应中附带 `descriptor` 字段。或者:thread-detail 发现 workflow name 后,请求 `GET /workflows/:name/descriptor` 拿到 graph。
## 前端渲染
### 库选型:React Flow + dagre
| 库 | 优势 | 劣势 |
|---|---|---|
| **React Flow** ✅ | React 原生、自定义节点/边、dagre 自动布局、~50KB gzip | 需要学 API |
| Mermaid | 声明式简单 | 无交互、无法高亮当前步骤 |
| D3 | 完全控制 | 太底层,手撸成本高 |
| Cytoscape | 图论强 | React 集成差 |
**依赖新增**
```json
{
"@xyflow/react": "^12",
"@dagrejs/dagre": "^1"
}
```
### 图结构映射
```
WorkflowGraph.edges → React Flow nodes + edges
节点(自动从 edges 推导):
- __start__ → 圆形小节点(入口)
- role → 圆角矩形,显示 role name + description
- __end__ → 圆形小节点(终止)
边:
- FALLBACK → 虚线(dashed),无 label
- condition → 实线,label = condition
hover tooltip = conditionDescription
```
### 布局
使用 dagre 自动计算 TB(top-to-bottom)方向布局:
```ts
import Dagre from "@dagrejs/dagre";
function layoutGraph(nodes, edges) {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
for (const node of nodes) {
g.setNode(node.id, { width: 180, height: 60 });
}
for (const edge of edges) {
g.setEdge(edge.source, edge.target);
}
Dagre.layout(g);
return nodes.map((node) => {
const pos = g.node(node.id);
return { ...node, position: { x: pos.x - 90, y: pos.y - 30 } };
});
}
```
### 运行时高亮
ThreadDetail 已有 `records: ThreadRecord[]`,其中 `RoleRecord.role` 就是当前/历史执行的 role。
高亮逻辑:
```ts
function getNodeStates(records: ThreadRecord[]): Map<string, "completed" | "active"> {
const states = new Map<string, "completed" | "active">();
const roleRecords = records.filter((r) => r.type === "role");
for (let i = 0; i < roleRecords.length; i++) {
const role = roleRecords[i].role;
states.set(role, i === roleRecords.length - 1 ? "active" : "completed");
}
// 如果有 workflow-result,最后一个 role 也是 completed
if (records.some((r) => r.type === "workflow-result")) {
for (const [k] of states) {
states.set(k, "completed");
}
states.set("__end__", "completed");
}
states.set("__start__", "completed");
return states;
}
```
节点样式:
| 状态 | 样式 |
|------|------|
| default | `border: var(--color-border)`, 暗色背景 |
| completed | `border: var(--color-success)`, 绿色边框 + ✓ 图标 |
| active | `border: var(--color-accent)`, 蓝色边框 + 脉冲动画 |
边高亮:当 source 和 target 都至少 completed 时,边变绿。
## 组件结构
```
workflow-dashboard/src/
components/
workflow-graph/
types.ts — NodeState 等前端类型
index.ts — export { WorkflowGraph }
workflow-graph.tsx — 主组件,React Flow canvas
role-node.tsx — 自定义 role 节点
terminal-node.tsx — START/END 圆形节点
condition-edge.tsx — 自定义边(虚线/实线 + label)
use-layout.ts — dagre 布局 hook
```
### 集成到 ThreadDetail
在 ThreadDetail 中,records 列表上方插入可折叠的图面板:
```tsx
// thread-detail.tsx
{graph && (
<div className="mb-4 border rounded-lg overflow-hidden" style={{ height: 300 }}>
<WorkflowGraph graph={graph} nodeStates={getNodeStates(records)} />
</div>
)}
```
图高度固定 300px,React Flow 支持 pan + zoom,不影响下方 records 滚动。
## 实施计划
### ~~Phase 0: 数据层~~ ✅ Done (PR #201)
- [x] `WorkflowDefinition.moderator``table` (ModeratorTable)
- [x] `WorkflowDescriptor` 新增 `graph: WorkflowGraph`
- [x] `buildDescriptor` 自动提取 graph
- [x] `validateWorkflowDescriptor` 校验 graph
### Phase 1: API + 静态图渲染
1. serve API:`GET /workflows/:name` 返回 descriptor(含 graph),或新增 `GET /workflows/:name/descriptor`
2. Dashboard `api.ts` 新增 `getWorkflowDescriptor(agent, name)` 函数
3. 安装 `@xyflow/react` + `@dagrejs/dagre`
4. 实现 `workflow-graph/` 组件集
5. ThreadDetail 中集成:从 thread-start record 拿 workflow name → 请求 descriptor → 渲染图
**产出**:打开 ThreadDetail 看到 workflow 流程图,无高亮。
### Phase 2: 运行时高亮
1. ThreadDetail 根据 records 计算 nodeStates
2. 节点/边样式响应状态变化
3. SSE live 模式下实时更新高亮
**产出**:正在运行的 thread 能看到当前执行到哪个 role。
### Phase 3: 交互增强
1. 点击节点滚动到对应 role 的 RecordCard
2. 边 hover 显示 conditionDescription tooltip
3. 节点 hover 显示 role description + schema summary
**产出**:图和记录列表联动。
## 注意事项
- **自循环边**:如 `coder → coder (FALLBACK)`,React Flow 支持自循环,dagre 需要特殊处理(self-edge 用 loop 路径)
- **大图性能**:dagre 在 <50 节点时性能无忧,workflow 通常 <10 个 role
- **暗色主题**:Dashboard 已使用 CSS variables,节点/边样式复用现有色板
- **不提交 pnpm-lock.yaml**
+191
View File
@@ -0,0 +1,191 @@
# workflow-agent-react — ReAct Agent Package
**Status**: RFC v3
**Author**: 小橘 🍊
## Problem
现有的 agent 包都依赖外部 CLI 进程:
| Package | 机制 | 能力 |
|---------|------|------|
| `workflow-agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
## 核心设计变更:AdapterFn 替代 AgentFn
### 现状的问题
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
```
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
```
### 新抽象:AdapterFn
```typescript
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
```
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
- **`schema`** — role 的 meta schema,定义输出格式
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
### AgentContext 不再需要
`AgentContext``ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
### createWorkflow 签名变更
```typescript
// Before
type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// After
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
```
engine 对每个 role 的执行逻辑:
```typescript
// Before
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
// After
const roleFn = adapter(role.systemPrompt, role.metaSchema);
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
```
## `createReactAdapter` — 复用 workflow-reactor
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor``ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
```typescript
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { ThreadContext, LlmProvider } from "@uncaged/workflow-protocol";
import type { ToolDefinition } from "@uncaged/workflow-reactor";
type ReactToolHandler = (name: string, args: string) => Promise<string>;
type ReactAdapterConfig = {
provider: LlmProvider;
tools: readonly ToolDefinition[];
toolHandler: ReactToolHandler;
maxRounds: number;
};
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const reactor = createThreadReactor<ThreadContext>({
llm: createLlmFn(config.provider),
staticTools: config.tools,
structuredToolFromSchema: (s) => buildStructuredTool(s),
systemPromptForStructuredTool: () => prompt,
toolHandler: (call, ctx) =>
config.toolHandler(call.function.name, call.function.arguments),
maxRounds: config.maxRounds,
});
return async (ctx: ThreadContext): Promise<T> => {
const input = buildThreadInput(ctx);
const result = await reactor({ thread: ctx, input, schema });
if (!result.ok) throw new Error(result.error);
return result.value;
};
};
}
```
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
## `agentToAdapter` — 向后兼容
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`
```typescript
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext): Promise<T> => {
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
const result = await agent(agentCtx);
const output = typeof result === "string" ? result : result.output;
return extract(output, schema, extractProvider);
};
};
}
```
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
## 包结构
```
packages/workflow-agent-react/
src/
types.ts # ReactAdapterConfig, ReactToolHandler
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
thread-input.ts # ThreadContext → user message string
index.ts
__tests__/
create-react-adapter.test.ts
package.json
```
依赖:
- `@uncaged/workflow-protocol``ThreadContext`, `LlmProvider`
- `@uncaged/workflow-reactor``createLlmFn`, `createThreadReactor`, types
## 影响范围
### Breaking Changes
| 改动 | 影响 |
|------|------|
| `AgentBinding``AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
### 需修改的包
1. `workflow-protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
2. `workflow-runtime` — 更新 re-export
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
4. `workflow-util-agent``buildAgentPrompt``buildThreadInput`,接收 `ThreadContext`
5. 所有 bundle-entry — `agent:``adapter:`
### 不受影响
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `workflow-dashboard`
- `workflow-agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
## Phases
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
## 工具集(后续讨论)
| 工具 | 说明 | 优先级 |
|------|------|--------|
| `read_file` | 读文件 | P0 |
| `write_file` | 写文件 | P0 |
| `patch_file` | find-and-replace 编辑 | P0 |
| `shell_exec` | 执行 shell 命令 | P0 |
| `search_files` | grep / find | P1 |
| `list_files` | ls | P1 |
+6 -1
View File
@@ -9,7 +9,12 @@
"check": "bunx tsc --build && biome check .",
"typecheck": "bunx tsc --build",
"format": "biome format --write .",
"test": "bun run --filter '*' test"
"test": "bun run --filter '*' test",
"link": "./scripts/link-all.sh",
"link:consume": "./scripts/link-all.sh --consume",
"link:unlink": "./scripts/link-all.sh --unlink",
"publish:gitea": "./scripts/publish-all.sh",
"publish:gitea:dry": "./scripts/publish-all.sh --dry-run"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
@@ -17,7 +17,7 @@ import {
} from "../src/commands/workflow/index.js";
import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
`;
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
@@ -153,6 +153,7 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
schema: { type: "object", properties: { greeting: { type: "string" } } },
},
},
graph: { edges: [] },
};
${wfPutImport}
export const run = async function* (input, options) {
@@ -24,6 +24,7 @@ export const descriptor = {
coder: { description: "coder", schema: {} },
reviewer: { description: "reviewer", schema: {} },
},
graph: { edges: [] },
};
export const run = async function* (input, options) {
const cas = options.cas;
@@ -45,8 +45,8 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
@@ -100,8 +100,8 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
@@ -135,8 +135,8 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
@@ -58,6 +58,11 @@ describe("--help flag on groups", () => {
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
expect(code).toBe(0);
});
test("setup --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["setup", "--help"]);
expect(code).toBe(0);
});
});
describe("getSkillTopics", () => {
@@ -90,6 +95,8 @@ describe("formatCliUsage", () => {
expect(u).toContain("Thread execution:");
expect(u).toContain("Content-addressable storage:");
expect(u).toContain("Development:");
expect(u).toContain("Configuration:");
expect(u).toContain("setup [--provider <name>]");
expect(u).toContain("Shortcuts:");
expect(u).toContain("Reference:");
expect(u).toContain("skill [topic]");
@@ -128,6 +135,7 @@ describe("formatSkillTopic('cli')", () => {
expect(doc).toContain("### thread");
expect(doc).toContain("### cas");
expect(doc).toContain("### init");
expect(doc).toContain("### setup");
expect(doc).toContain("### Top-level shortcuts");
});
@@ -64,6 +64,7 @@ describe("init template", () => {
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
expect(moder).not.toContain("export default");
expect(moder).toContain("ModeratorTable");
});
test("finds workspace walking up from nested cwd", async () => {
@@ -38,8 +38,16 @@ describe("init workspace", () => {
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
workspaces: string[];
scripts: { bundle: string };
};
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
expect(rootPkg.scripts.bundle).toBe("bun run scripts/bundle.ts");
expect(await pathExists(join(root, "scripts", "bundle.ts"))).toBe(true);
const bundleSrc = await readFile(join(root, "scripts", "bundle.ts"), "utf8");
expect(bundleSrc).toContain("Bun.build");
expect(bundleSrc).toContain("-entry.ts");
expect(bundleSrc).toContain("distDir");
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
type: string;
@@ -82,7 +90,7 @@ describe("init workspace", () => {
for (const term of [
"RoleDefinition",
"WorkflowDefinition",
"Moderator",
"ModeratorTable",
"AgentFn",
"ExtractFn",
"RoleMeta",
@@ -117,9 +125,6 @@ describe("init workspace", () => {
});
test("errors on invalid workspace name", async () => {
const slash = await cmdInitWorkspace(parent, "a/b");
expect(slash.ok).toBe(false);
const dots = await cmdInitWorkspace(parent, "..");
expect(dots.ok).toBe(false);
@@ -127,6 +132,14 @@ describe("init workspace", () => {
expect(empty.ok).toBe(false);
});
test("accepts nested path as workspace name", async () => {
const nested = await cmdInitWorkspace(parent, "a/b");
expect(nested.ok).toBe(true);
if (nested.ok) {
expect(nested.value.rootPath).toContain("a/b");
}
});
test("usage lists init subcommands", () => {
const u = formatCliUsage();
expect(u).toContain("init workspace <name>");
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readWorkflowRegistry } from "@uncaged/workflow-register";
import { runCli } from "../src/cli-dispatch.js";
import { cmdSetup } from "../src/commands/setup/index.js";
describe("setup command (CLI mode)", () => {
let prevEnv: string | undefined;
let storageRoot: string;
beforeEach(async () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-setup-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await mkdir(storageRoot, { recursive: true });
});
afterEach(async () => {
if (prevEnv === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
}
await rm(storageRoot, { recursive: true, force: true });
});
test("writes workflow.yaml with provider, models.default, and depth defaults", async () => {
const r = await cmdSetup(storageRoot, {
provider: "dashscope",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test123",
defaultModel: "dashscope/qwen-plus",
initWorkspaceName: null,
});
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok) {
return;
}
expect(reg.value.config).not.toBeNull();
if (reg.value.config === null) {
return;
}
expect(reg.value.config.providers.dashscope).toEqual({
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test123",
});
expect(reg.value.config.models.default).toBe("dashscope/qwen-plus");
expect(reg.value.config.maxDepth).toBe(3);
expect(reg.value.config.supervisorInterval).toBe(3);
const raw = await readFile(join(storageRoot, "workflow.yaml"), "utf8");
expect(raw).toContain("dashscope");
expect(raw).toContain("qwen-plus");
});
test("idempotent: second run updates apiKey and preserves workflows", async () => {
const initialYaml = `config:
maxDepth: 7
supervisorInterval: 2
providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
apiKey: sk-old
models:
default: dashscope/qwen-plus
workflows:
keep-me:
hash: "0000000000000"
timestamp: 1
history: []
`;
await writeFile(join(storageRoot, "workflow.yaml"), initialYaml, "utf8");
const r2 = await cmdSetup(storageRoot, {
provider: "dashscope",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-newkey",
defaultModel: "dashscope/qwen-plus",
initWorkspaceName: null,
});
expect(r2.ok).toBe(true);
if (!r2.ok) {
return;
}
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok || reg.value.config === null) {
return;
}
expect(reg.value.config.providers.dashscope.apiKey).toBe("sk-newkey");
expect(reg.value.config.maxDepth).toBe(7);
expect(reg.value.config.supervisorInterval).toBe(2);
expect(reg.value.workflows["keep-me"]).toBeDefined();
if (reg.value.workflows["keep-me"] === undefined) {
return;
}
expect(reg.value.workflows["keep-me"].hash).toBe("0000000000000");
});
test("runCli setup dispatches with flags and exits 0", async () => {
const code = await runCli(storageRoot, [
"setup",
"--provider",
"openai",
"--base-url",
"https://api.openai.com/v1",
"--api-key",
"sk-test",
"--default-model",
"openai/gpt-4o",
]);
expect(code).toBe(0);
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok || reg.value.config === null) {
return;
}
expect(reg.value.config.providers.openai.apiKey).toBe("sk-test");
expect(reg.value.config.models.default).toBe("openai/gpt-4o");
});
});
@@ -36,6 +36,7 @@ const threadFixtureDescriptor = `export const descriptor = {
only: { description: "only", schema: {} },
noop: { description: "noop", schema: {} },
},
graph: { edges: [] },
};
`;
@@ -186,6 +187,14 @@ describe("cli thread commands", () => {
}
expect(shown.value.includes('"threadId"')).toBe(true);
const parsed = JSON.parse(shown.value) as Record<string, unknown>;
expect(parsed.parentState).toBeNull();
const parsedSteps = parsed.steps as Array<Record<string, unknown>>;
for (const step of parsedSteps) {
expect(step).toHaveProperty("childThread");
expect(step.childThread).toBeNull();
}
const removed = await cmdThreadRemove(storageRoot, threadId);
expect(removed.ok).toBe(true);
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.3.1",
"version": "0.3.11",
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-gateway": "workspace:*",
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
@@ -5,6 +5,7 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher } from "./commands/cas/index.js";
import { createInitDispatcher } from "./commands/init/index.js";
import { dispatchServe } from "./commands/serve/index.js";
import { dispatchSetup } from "./commands/setup/index.js";
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
@@ -66,6 +67,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
thread: dispatchThread,
cas: dispatchCas,
init: dispatchInit,
setup: dispatchSetup,
skill: dispatchSkill,
run: dispatchRun,
live: dispatchLive,
+13
View File
@@ -5,6 +5,15 @@ import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
const SETUP_USAGE_COMMANDS = [
{
name: "",
args: "[--provider <name>] [--base-url <url>] [--api-key <key>] [--default-model <provider/model>] [--init-workspace <name>]",
description:
"Configure workflow.yaml LLM providers and default model (interactive when no flags)",
},
] as const;
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
return [
{
@@ -39,6 +48,10 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
description: e.description,
})),
},
{
name: "setup",
commands: [...SETUP_USAGE_COMMANDS],
},
];
}
+3 -1
View File
@@ -12,6 +12,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
thread: "Thread execution:",
cas: "Content-addressable storage:",
init: "Development:",
setup: "Configuration:",
};
export function formatUsageCommandLines(
@@ -38,9 +39,10 @@ export function formatCliUsage(
}
lines.push(sectionTitle);
const rows = group.commands.map((cmd) => {
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
const args = cmd.args ? ` ${cmd.args}` : "";
return {
prefix: `${group.name} ${cmd.name}${args}`,
prefix: `${group.name}${namePart}${args}`,
description: cmd.description,
};
});
@@ -6,7 +6,7 @@ export function templatePackageJson(templateName: string): string {
private: true,
type: "module",
dependencies: {
"@uncaged/workflow-runtime": "^0.1.0",
"@uncaged/workflow-runtime": "^0.3.1",
zod: "^4.0.0",
},
},
@@ -57,17 +57,13 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
}
export function templateModeratorTs(): string {
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
return `import { END, START, type ModeratorTable } from "@uncaged/workflow-runtime";
import type { HelloTemplateMeta } from "./roles.js";
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
ctx: ModeratorContext<HelloTemplateMeta>,
) => {
if (ctx.steps.length === 0) {
return "greeter";
}
return END;
export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
`;
}
@@ -75,7 +71,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
export function templateIndexTs(): string {
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { helloTemplateModerator } from "./moderator.js";
import { helloTemplateTable } from "./moderator.js";
import {
HELLO_TEMPLATE_DESCRIPTION,
type HelloTemplateMeta,
@@ -87,14 +83,14 @@ export {
type HelloTemplateMeta,
greeterRole,
} from "./roles.js";
export { helloTemplateModerator } from "./moderator.js";
export { helloTemplateTable } from "./moderator.js";
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
description: HELLO_TEMPLATE_DESCRIPTION,
roles: {
greeter: greeterRole,
},
moderator: helloTemplateModerator,
table: helloTemplateTable,
};
`;
}
@@ -1,5 +1,5 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { basename, join, resolve } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
@@ -14,6 +14,9 @@ function rootPackageJson(workspaceName: string): string {
private: true,
type: "module",
workspaces: ["templates/*", "workflows"],
scripts: {
bundle: "bun run scripts/bundle.ts",
},
},
null,
2,
@@ -28,7 +31,7 @@ function workflowsPackageJson(): string {
private: true,
type: "module",
dependencies: {
"@uncaged/workflow-runtime": "^0.1.0",
"@uncaged/workflow-runtime": "^0.3.1",
zod: "^4.0.0",
},
},
@@ -42,7 +45,9 @@ function biomeJson(): string {
{
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
files: {
includes: ["**", "!**/node_modules", "!**/dist"],
// Exclude generated bundle script — it uses Bun globals and console that
// conflict with the workspace's Biome rules (noConsole, etc.).
includes: ["**", "!**/node_modules", "!**/dist", "!scripts/bundle.ts"],
},
formatter: {
indentWidth: 2,
@@ -85,7 +90,7 @@ function agentsMd(): string {
| 层级 | 目录 / 产物 | 职责 |
|------|----------------|------|
| **Workspace** | 仓库根(\`package.json\`\`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
@@ -94,19 +99,19 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
- **RoleDefinition<Meta>**:纯数据——\`description\`\`systemPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)
- **ModeratorTable**:\`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
引擎循环简述:**ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
## 3. 开发流程
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`
3. **编写 Moderator**:根据 \`ctx.steps\`业务状态返回下一个角色名或 \`END\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
3. **编写 ModeratorTable**: \`START\`各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
@@ -153,7 +158,13 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
---
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
`;
}
function bunfigToml(): string {
return `[install.scopes]
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
`;
}
@@ -164,7 +175,7 @@ Local workflow development workspace (Bun monorepo).
## Layout
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
## Commands
@@ -184,32 +195,137 @@ uncaged-workflow init workspace ${workspaceName}
`;
}
function bundleTs(): string {
return [
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
'import { join } from "node:path";',
"",
'const rootDir = join(import.meta.dir, "..");',
'const workflowsDir = join(rootDir, "workflows");',
'const distDir = join(rootDir, "dist");',
"",
"type JsonDeps = {",
" dependencies: Record<string, string> | null;",
" devDependencies: Record<string, string> | null;",
"};",
"",
"function isEntryFile(name: string): boolean {",
' return name.endsWith("-entry.ts");',
"}",
"",
"function entryStem(name: string): string {",
' return name.slice(0, -".ts".length);',
"}",
"",
"async function uncagedWorkflowExternals(): Promise<string[]> {",
" const names = new Set<string>();",
' const paths = [join(rootDir, "package.json"), join(workflowsDir, "package.json")];',
" for (const pkgPath of paths) {",
" let raw: string;",
" try {",
' raw = await readFile(pkgPath, "utf8");',
" } catch {",
" continue;",
" }",
" const parsed = JSON.parse(raw) as JsonDeps;",
" const blocks = [parsed.dependencies, parsed.devDependencies];",
" for (const block of blocks) {",
" if (block == null) {",
" continue;",
" }",
" for (const key of Object.keys(block)) {",
' if (key.startsWith("@uncaged/workflow")) {',
" names.add(key);",
" }",
" }",
" }",
" }",
" if (names.size === 0) {",
' names.add("@uncaged/workflow-runtime");',
' names.add("@uncaged/workflow-protocol");',
" }",
" return [...names];",
"}",
"",
"async function main(): Promise<void> {",
" await mkdir(distDir, { recursive: true });",
" let files: string[];",
" try {",
" files = await readdir(workflowsDir);",
" } catch {",
' console.error("bundle: missing workflows/ directory");',
" process.exitCode = 1;",
" return;",
" }",
" const entries = files.filter(isEntryFile);",
" if (entries.length === 0) {",
' console.warn("bundle: no *-entry.ts files under workflows/");',
" return;",
" }",
" const external = await uncagedWorkflowExternals();",
" for (const file of entries) {",
" const stem = entryStem(file);",
" const entryPath = join(workflowsDir, file);",
" const result = await Bun.build({",
" entrypoints: [entryPath],",
" outdir: distDir,",
' format: "esm",',
' target: "node",',
" splitting: false,",
' naming: { entry: "[name].esm.js" },',
" external,",
" });",
" if (!result.success) {",
" for (const log of result.logs) {",
" console.error(log);",
" }",
` throw new Error(\`bundle failed for \${file}\`);`,
" }",
" const dts =",
` 'export { run, descriptor } from "../workflows/' + stem + '.js";\\n';`,
` await writeFile(join(distDir, \`\${stem}.d.ts\`), dts, "utf8");`,
` console.log(\`bundle: \${stem} -> dist/\${stem}.esm.js\`);`,
" }",
"}",
"",
"await main();",
"",
].join("\n");
}
export async function cmdInitWorkspace(
parentDir: string,
workspaceName: string,
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
const validated = validateWorkspaceSegment(workspaceName);
if (!validated.ok) {
return validated;
// Accept a relative/absolute path: resolve it and derive the dir name for package.json.
const resolved = resolve(parentDir, workspaceName);
const rootPath = resolved;
const dirName = basename(resolved);
if (dirName === "" || dirName === "." || dirName === "..") {
return err(`invalid workspace path: ${workspaceName}`);
}
const rootPath = join(parentDir, workspaceName);
if (await pathExists(rootPath)) {
return err(`directory already exists: ${rootPath}`);
}
await mkdir(rootPath, { recursive: false });
await mkdir(join(rootPath, "templates"), { recursive: false });
await mkdir(join(rootPath, "workflows"), { recursive: false });
await mkdir(rootPath, { recursive: true });
await mkdir(join(rootPath, "templates"), { recursive: true });
await mkdir(join(rootPath, "workflows"), { recursive: true });
await mkdir(join(rootPath, "scripts"), { recursive: true });
await Promise.all([
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
writeFile(join(rootPath, "package.json"), rootPackageJson(dirName), "utf8"),
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
writeFile(join(rootPath, "README.md"), readmeMd(dirName), "utf8"),
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
writeFile(join(rootPath, "workflows", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
]);
return ok({ rootPath });
@@ -1,9 +1,14 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { WorkflowDescriptor } from "@uncaged/workflow-protocol";
import {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
readWorkflowRegistry,
validateWorkflowDescriptor,
} from "@uncaged/workflow-register";
import { Hono } from "hono";
import { parse as parseYaml } from "yaml";
export function createWorkflowRoutes(storageRoot: string): Hono {
const app = new Hono();
@@ -35,7 +40,17 @@ export function createWorkflowRoutes(storageRoot: string): Hono {
if (entry === null) {
return c.json({ error: `workflow not found: ${name}` }, 404);
}
return c.json({ name, ...entry });
let descriptor: WorkflowDescriptor | null = null;
try {
const yamlPath = join(storageRoot, "bundles", `${entry.hash}.yaml`);
const yamlText = await readFile(yamlPath, "utf8");
const parsed: unknown = parseYaml(yamlText);
const validated = validateWorkflowDescriptor(parsed);
descriptor = validated.ok ? validated.value : null;
} catch {
descriptor = null;
}
return c.json({ name, ...entry, descriptor });
});
app.get("/:name/history", async (c) => {
@@ -1,17 +1,14 @@
import { randomUUID } from "node:crypto";
import { hostname as osHostname } from "node:os";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { createLogger } from "@uncaged/workflow-util";
import { serve } from "bun";
import { printCliLine } from "../../cli-output.js";
import { createApp } from "./app.js";
import {
registerWithGateway,
startHeartbeat,
startTunnel,
unregisterFromGateway,
} from "./tunnel.js";
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./tunnel.js";
import type { ServeOptions } from "./types.js";
import { startGatewayWsClient } from "./ws-client.js";
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
const HEARTBEAT_INTERVAL_MS = 60_000;
@@ -56,6 +53,7 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
let hostname = "127.0.0.1";
let name = osHostname().split(".")[0].toLowerCase();
let noTunnel = false;
let tunnelUrl: string | null = null;
let gatewayUrl = DEFAULT_GATEWAY_URL;
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
const stringFlags: Record<string, (v: string) => void> = {
@@ -68,6 +66,9 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
"--gateway": (v) => {
gatewayUrl = v;
},
"--tunnel-url": (v) => {
tunnelUrl = v;
},
};
for (let i = 0; i < argv.length; i++) {
@@ -87,7 +88,7 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
}
}
return ok({ port, hostname, name, noTunnel, gatewayUrl, gatewaySecret });
return ok({ port, hostname, name, noTunnel, tunnelUrl, gatewayUrl, gatewaySecret });
}
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
@@ -107,47 +108,64 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
return 0;
}
// Start cloudflared quick tunnel
printCliLine("starting cloudflared quick tunnel...");
const tunnel = await startTunnel(options.port);
let resolvedTunnelUrl: string;
let stopWsClient: (() => void) | null = null;
if (!tunnel) {
printCliLine("failed to create tunnel — continuing without gateway registration");
await new Promise(() => {});
return 0;
if (options.tunnelUrl !== null) {
resolvedTunnelUrl = options.tunnelUrl;
printCliLine(`using tunnel URL: ${resolvedTunnelUrl}`);
} else {
if (options.gatewaySecret === "") {
printCliLine(
"WORKFLOW_GATEWAY_SECRET not set — cannot use WebSocket gateway connection (set env or pass --tunnel-url)",
);
await new Promise(() => {});
return 0;
}
resolvedTunnelUrl = `http://127.0.0.1:${options.port}`;
const log = createLogger({ sink: { kind: "stderr" } });
stopWsClient = startGatewayWsClient({
gatewayUrl: options.gatewayUrl,
name: options.name,
secret: options.gatewaySecret,
localPort: options.port,
log,
});
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
}
printCliLine(`tunnel: ${tunnel.url}`);
// Register with gateway
if (options.gatewaySecret) {
if (agentToken === null) {
printCliLine("internal error: agent token missing");
await new Promise(() => {});
return 1;
}
const token = agentToken;
const registered = await registerWithGateway(
options.gatewayUrl,
options.name,
tunnel.url,
resolvedTunnelUrl,
options.gatewaySecret,
agentToken!,
token,
);
if (registered) {
printCliLine(`registered with gateway as "${options.name}"`);
}
// Start heartbeat
const heartbeatTimer = startHeartbeat(
options.gatewayUrl,
options.name,
tunnel.url,
resolvedTunnelUrl,
options.gatewaySecret,
agentToken!,
token,
HEARTBEAT_INTERVAL_MS,
);
// Cleanup on exit
const cleanup = async () => {
clearInterval(heartbeatTimer);
stopWsClient?.();
printCliLine("unregistering from gateway...");
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
tunnel.process.kill();
process.exit(0);
};
@@ -157,7 +175,6 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
}
// Keep process alive
await new Promise(() => {});
return 0;
}
@@ -3,6 +3,7 @@ export type ServeOptions = {
hostname: string;
name: string;
noTunnel: boolean;
tunnelUrl: string | null;
gatewayUrl: string;
gatewaySecret: string;
};
@@ -0,0 +1,165 @@
import { parseWsRequestJson, type WsResponse } from "@uncaged/workflow-gateway/ws-protocol";
import type { LogFn } from "@uncaged/workflow-util";
export type GatewayWsClientParams = {
gatewayUrl: string;
name: string;
secret: string;
localPort: number;
log: LogFn;
};
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30_000;
export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secret: string): string {
const u = new URL(gatewayUrl);
if (u.protocol === "https:") {
u.protocol = "wss:";
} else if (u.protocol === "http:") {
u.protocol = "ws:";
}
u.pathname = "/ws/connect";
u.search = "";
u.searchParams.set("name", name);
u.searchParams.set("secret", secret);
return u.href;
}
function headersToRecord(h: Headers): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of h) {
out[k] = v;
}
return out;
}
async function handleGatewayMessage(
ws: WebSocket,
raw: string,
params: GatewayWsClientParams,
): Promise<void> {
const req = parseWsRequestJson(raw);
if (req === null) {
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
return;
}
const localUrl = `http://127.0.0.1:${String(params.localPort)}${req.path}`;
const initHeaders = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
initHeaders.set(k, v);
}
let resp: Response;
try {
resp = await fetch(localUrl, {
method: req.method,
headers: initHeaders,
body: req.body === null ? undefined : req.body,
});
} catch (e) {
params.log("R4N7BQ3C", `local proxy fetch failed: ${String(e)}`);
const errBody: WsResponse = {
id: req.id,
status: 502,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: "local fetch failed", detail: String(e) }),
};
ws.send(JSON.stringify(errBody));
return;
}
const bodyText = await resp.text();
const headerRecord = headersToRecord(resp.headers);
const out: WsResponse = {
id: req.id,
status: resp.status,
headers: headerRecord,
body: bodyText,
};
ws.send(JSON.stringify(out));
}
/** Maintains a reverse WebSocket to the workflow gateway; reconnects with exponential backoff. */
export function startGatewayWsClient(params: GatewayWsClientParams): () => void {
const wsUrl = buildGatewayWsConnectUrl(params.gatewayUrl, params.name, params.secret);
let socket: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
let attempt = 0;
const clearReconnectTimer = (): void => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const scheduleReconnect = (): void => {
if (stopped) {
return;
}
clearReconnectTimer();
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
attempt++;
params.log("6CJX2RLP", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
reconnectTimer = setTimeout(connect, delayMs);
};
const connect = (): void => {
if (stopped) {
return;
}
clearReconnectTimer();
params.log("2XK7HM9Q", "gateway WebSocket connecting...");
try {
socket = new WebSocket(wsUrl);
} catch (e) {
params.log("7NQW4HBT", `gateway WebSocket create failed: ${String(e)}`);
scheduleReconnect();
return;
}
const ws = socket;
ws.addEventListener("open", () => {
attempt = 0;
params.log("4PWN3V82", "gateway WebSocket connected");
});
ws.addEventListener("close", (ev) => {
socket = null;
params.log(
"8QTR6ZKC",
`gateway WebSocket closed code=${String(ev.code)} reason=${ev.reason} wasClean=${String(ev.wasClean)}`,
);
if (!stopped) {
scheduleReconnect();
}
});
ws.addEventListener("error", () => {
params.log("9BWS1M7F", "gateway WebSocket error");
});
ws.addEventListener("message", (ev) => {
const data = ev.data;
if (typeof data !== "string") {
params.log("T9W2KL5H", "gateway WebSocket non-text frame ignored");
return;
}
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
});
});
};
connect();
return (): void => {
stopped = true;
clearReconnectTimer();
if (socket !== null && socket.readyState === WebSocket.OPEN) {
socket.close(1000, "shutdown");
}
socket = null;
};
}
@@ -0,0 +1,399 @@
import { existsSync } from "node:fs";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { resolve as resolvePath } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { createLogger } from "@uncaged/workflow-util";
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
const setupDispatchLog = createLogger({ sink: { kind: "stderr" } });
import { loadPresetProviders } from "./preset-providers.js";
import { cmdSetup, printSetupSummary } from "./setup.js";
import type { SetupCliArgs } from "./types.js";
type OpenAiModelEntry = {
id: string;
};
type OpenAiModelsResponse = {
data: OpenAiModelEntry[];
};
function usageSetup(): string {
return [
"uncaged-workflow setup — configure workflow.yaml providers and default model",
"",
"Non-interactive (agent mode):",
" uncaged-workflow setup \\",
" --provider <name> \\",
" --base-url <url> \\",
" --api-key <key> \\",
" --default-model <provider/model> \\",
" [--init-workspace <name>]",
"",
"Interactive: run with no flags (prompts for each value).",
"",
"Storage: uses the same root as other commands (see UNCAGED_WORKFLOW_STORAGE_ROOT).",
].join("\n");
}
function requireNext(argv: string[], i: number, flag: string): Result<string, string> {
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
return err(`${flag} requires a value`);
}
return ok(next);
}
type ParsedSetup = SetupCliArgs | "interactive" | "help";
type SetupFlagField = "provider" | "baseUrl" | "apiKey" | "defaultModel" | "initWorkspaceName";
const SETUP_FLAG_TO_FIELD: Record<string, SetupFlagField> = {
"--provider": "provider",
"--base-url": "baseUrl",
"--api-key": "apiKey",
"--default-model": "defaultModel",
"--init-workspace": "initWorkspaceName",
};
function emptyFlagState(): Record<SetupFlagField, string | null> {
return {
provider: null,
baseUrl: null,
apiKey: null,
defaultModel: null,
initWorkspaceName: null,
};
}
function finalizeParsedSetup(
state: Record<SetupFlagField, string | null>,
): Result<ParsedSetup, string> {
const hasAnyFlag =
state.provider !== null ||
state.baseUrl !== null ||
state.apiKey !== null ||
state.defaultModel !== null ||
state.initWorkspaceName !== null;
if (!hasAnyFlag) {
return ok("interactive");
}
if (state.provider === null) {
return err(
"non-interactive setup requires --provider (or omit all flags for interactive mode)",
);
}
const missing: string[] = [];
if (state.baseUrl === null) {
missing.push("--base-url");
}
if (state.apiKey === null) {
missing.push("--api-key");
}
if (state.defaultModel === null) {
missing.push("--default-model");
}
if (missing.length > 0) {
return err(`missing required flag(s): ${missing.join(", ")}`);
}
const b = state.baseUrl;
const k = state.apiKey;
const m = state.defaultModel;
if (b === null || k === null || m === null) {
return err("internal: missing required flags after validation");
}
return ok({
provider: state.provider,
baseUrl: b,
apiKey: k,
defaultModel: m,
initWorkspaceName: state.initWorkspaceName,
});
}
function parseSetupArgv(argv: string[]): Result<ParsedSetup, string> {
const state = emptyFlagState();
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === undefined) {
break;
}
if (tok === "--help" || tok === "-h") {
return ok("help");
}
const field = SETUP_FLAG_TO_FIELD[tok];
if (field === undefined) {
return err(`unknown argument: ${tok}`);
}
const v = requireNext(argv, i, tok);
if (!v.ok) {
return v;
}
state[field] = v.value;
i++;
}
return finalizeParsedSetup(state);
}
async function promptLine(
rl: { question: (q: string) => Promise<string> },
label: string,
): Promise<string> {
const raw = await rl.question(label);
return raw.trim();
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
return new Promise((fulfill) => {
let buf = "";
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf8");
const onData = (chunk: string) => {
for (const c of chunk.toString()) {
if (c === "\n" || c === "\r" || c === "\u0004") {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.stdin.pause();
process.stdin.removeListener("data", onData);
process.stdout.write("\n");
fulfill(buf.trim());
return;
}
if (c === "\u007F" || c === "\b") {
if (buf.length > 0) {
buf = buf.slice(0, -1);
process.stdout.write("\b \b");
}
continue;
}
if (c === "\u0003") {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.exit(130);
}
buf += c;
process.stdout.write("*");
}
};
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchAvailableModels(
baseUrl: string,
apiKey: string,
): Promise<string[]> {
const url = baseUrl.replace(/\/+$/, "") + "/models";
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
setupDispatchLog("R5KH7WM3", `GET ${url} returned ${res.status}`);
return [];
}
const body = (await res.json()) as OpenAiModelsResponse;
if (!Array.isArray(body.data)) {
return [];
}
// Filter out non-chat models. Some patterns are DashScope-specific (sambert, cosyvoice,
// wordart, wanx, wan2, paraformer) but harmless for other providers.
const NON_CHAT_RE =
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|z-image|s2s|livetranslate|realtime|gui-/i;
return body.data
.map((m) => m.id)
.filter((id) => !NON_CHAT_RE.test(id))
.sort();
} catch (e) {
setupDispatchLog("V8NQ4JT6", `fetch models failed: ${e instanceof Error ? e.message : String(e)}`);
return [];
}
}
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
const rl = createInterface({ input, output });
try {
printCliLine("Configure the LLM provider that workflow agents will use.\n");
const presets = loadPresetProviders();
const numWidth = String(presets.length + 1).length;
printCliLine("Select a provider:\n");
for (let i = 0; i < presets.length; i++) {
const p = presets[i]!;
const num = String(i + 1).padStart(numWidth);
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(presets.length + 1).padStart(numWidth);
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
printCliLine("");
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
rl.close();
return err(`invalid choice: ${choice}`);
}
let provider: string;
let baseUrl: string;
if (choiceNum <= presets.length) {
const selected = presets[choiceNum - 1]!;
provider = selected.name;
baseUrl = selected.baseUrl;
printCliLine(`\n → ${selected.label} (${baseUrl})\n`);
} else {
provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
if (provider === "") {
return err("provider name must not be empty");
}
baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
if (baseUrl === "") {
return err("base URL must not be empty");
}
}
// Close readline before raw-mode secret prompt, reopen after.
rl.close();
const apiKey = await promptSecret("API key for this provider: ");
if (apiKey === "") {
return err("API key must not be empty");
}
const rl2 = createInterface({ input, output });
// Try to list available models from the provider.
printCliLine("\nFetching available models...");
const models = await fetchAvailableModels(baseUrl, apiKey);
let selectedModel: string;
if (models.length > 0) {
printCliLine(`\nAvailable models (${models.length}):\n`);
const cols = process.stdout.columns || 80;
const nw = String(models.length).length; // number width
// Each cell: " <num>) <model> " — prefix is 2 + nw + 2 = nw+4
const prefixLen = nw + 4;
const maxModelLen = Math.max(...models.map((m) => m.length));
const cellWidth = prefixLen + maxModelLen + 2; // +2 gap between columns
const numCols = Math.max(1, Math.floor(cols / cellWidth));
for (let i = 0; i < models.length; i += numCols) {
const cells: string[] = [];
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
const num = String(j + 1).padStart(nw);
cells.push(` ${num}) ${(models[j]!).padEnd(maxModelLen + 2)}`);
}
printCliLine(cells.join(""));
}
printCliLine(`\nChoose a number, or type a model name directly.`);
const modelInput = await promptLine(rl2, `Default model [1-${models.length}]: `);
if (modelInput === "") {
rl2.close();
return err("default model must not be empty");
}
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
selectedModel = models[modelNum - 1]!;
} else {
// Treat as a literal model name.
selectedModel = modelInput;
}
} else {
printCliWarn("Could not fetch models (API may not support /models endpoint).");
const modelInput = await promptLine(rl2, `Default model (e.g. qwen-plus, gpt-4o): `);
if (modelInput === "") {
rl2.close();
return err("default model must not be empty");
}
selectedModel = modelInput;
}
// Strip provider prefix if user included one (e.g. pasted "MiniMax/MiniMax-M2.7").
const bare = selectedModel.includes("/") ? selectedModel.split("/").pop()! : selectedModel;
const defaultModel = `${provider}/${bare}`;
printCliLine(`${defaultModel}`);
let initWorkspaceName: string | null = null;
// Loop until a valid workspace path is provided or the user skips.
while (true) {
const wsPath = await promptLine(
rl2,
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
);
if (wsPath.toLowerCase() === "skip") {
break;
}
const candidate = wsPath === "" ? "./workflows" : wsPath;
// Validate path before passing to cmdSetup.
const resolved = resolvePath(process.cwd(), candidate);
if (existsSync(resolved)) {
printCliWarn(`directory already exists: ${resolved}`);
printCliLine("Please enter a different path, or type 'skip' to skip.");
continue;
}
initWorkspaceName = candidate;
break;
}
rl2.close();
return ok({
provider,
baseUrl,
apiKey,
defaultModel,
initWorkspaceName,
});
} catch (e) {
return err(e instanceof Error ? e.message : String(e));
}
}
export async function dispatchSetup(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseSetupArgv(argv);
if (!parsed.ok) {
printCliError(`${parsed.error}\n\n${usageSetup()}`);
return 1;
}
if (parsed.value === "help") {
printCliLine(usageSetup());
return 0;
}
let args: SetupCliArgs;
if (parsed.value === "interactive") {
const collected = await collectInteractiveSetup();
if (!collected.ok) {
printCliError(collected.error);
return 1;
}
args = collected.value;
} else {
args = parsed.value;
}
const result = await cmdSetup(storageRoot, args);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printSetupSummary(result.value);
return 0;
}
@@ -0,0 +1,4 @@
export { dispatchSetup } from "./dispatch.js";
export { loadPresetProviders } from "./preset-providers.js";
export { cmdSetup, printSetupSummary } from "./setup.js";
export type { CmdSetupSuccess, PresetProvider, SetupCliArgs } from "./types.js";
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import type { PresetProvider } from "./types.js";
type RawPresetEntry = {
name: unknown;
label: unknown;
baseUrl: unknown;
};
function isRawEntry(v: unknown): v is RawPresetEntry {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
}
let cached: ReadonlyArray<PresetProvider> | null = null;
export function loadPresetProviders(): ReadonlyArray<PresetProvider> {
if (cached !== null) return cached;
const yamlPath = join(import.meta.dirname, "providers.yaml");
const raw = readFileSync(yamlPath, "utf8");
const parsed: unknown = parseYaml(raw);
if (!Array.isArray(parsed)) {
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
}
const result: PresetProvider[] = [];
for (const entry of parsed) {
if (!isRawEntry(entry)) {
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
}
result.push({
name: entry.name as string,
label: entry.label as string,
baseUrl: entry.baseUrl as string,
});
}
cached = result;
return result;
}
@@ -0,0 +1,73 @@
# Preset LLM providers for `uncaged-workflow setup`.
# Each entry needs a provider name (used in workflow.yaml) and an OpenAI-compatible base URL.
# Add new providers here — no code changes required.
# ── International ──────────────────────────────────────────
- name: openai
label: OpenAI
baseUrl: https://api.openai.com/v1
- name: xai
label: xAI
baseUrl: https://api.x.ai/v1
- name: openrouter
label: OpenRouter
baseUrl: https://openrouter.ai/api/v1
- name: venice
label: Venice
baseUrl: https://api.venice.ai/api/v1
# ── China ──────────────────────────────────────────────────
- name: dashscope
label: DashScope (Alibaba)
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
- name: deepseek
label: DeepSeek
baseUrl: https://api.deepseek.com/v1
- name: siliconflow
label: SiliconFlow
baseUrl: https://api.siliconflow.cn/v1
- name: volcengine
label: Volcengine (ByteDance)
baseUrl: https://ark.cn-beijing.volces.com/api/v3
- name: kimi
label: Kimi (Moonshot)
baseUrl: https://api.moonshot.cn/v1
- name: glm
label: GLM (Zhipu AI)
baseUrl: https://open.bigmodel.cn/api/paas/v4
- name: glm-intl
label: GLM (Zhipu AI Intl)
baseUrl: https://api.z.ai/api/paas/v4
- name: stepfun
label: StepFun
baseUrl: https://api.stepfun.com/v1
- name: minimax
label: MiniMax
baseUrl: https://api.minimax.io/v1
- name: tencent
label: Tencent TokenHub
baseUrl: https://tokenhub.tencentmaas.com/v1
- name: xiaomi
label: Xiaomi MiMo
baseUrl: https://api.xiaomimimo.com/v1
# ── Local ──────────────────────────────────────────────────
- name: ollama
label: Ollama (local)
baseUrl: http://localhost:11434/v1
@@ -0,0 +1,105 @@
import { err, ok, type Result, type WorkflowConfig } from "@uncaged/workflow-protocol";
import {
readWorkflowRegistry,
splitProviderModelRef,
workflowRegistryPath,
writeWorkflowRegistry,
} from "@uncaged/workflow-register";
import { createLogger } from "@uncaged/workflow-util";
import { printCliLine } from "../../cli-output.js";
import { cmdInitWorkspace } from "../init/index.js";
import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
const setupLog = createLogger({ sink: { kind: "stderr" } });
function mergeWorkflowConfig(
prev: WorkflowConfig | null,
input: SetupCliArgs,
): Result<WorkflowConfig, string> {
const modelSplit = splitProviderModelRef(input.defaultModel);
if (!modelSplit.ok) {
return err(modelSplit.error);
}
if (modelSplit.value.providerName !== input.provider) {
return err(
`default model provider "${modelSplit.value.providerName}" must match --provider "${input.provider}"`,
);
}
const maxDepth = prev === null ? 3 : prev.maxDepth;
const supervisorInterval = prev === null ? 3 : prev.supervisorInterval;
const providers = {
...(prev === null ? {} : prev.providers),
[input.provider]: { baseUrl: input.baseUrl, apiKey: input.apiKey },
};
const models = { ...(prev === null ? {} : prev.models), default: input.defaultModel };
return ok({
maxDepth,
supervisorInterval,
providers,
models,
});
}
export async function cmdSetup(
storageRoot: string,
input: SetupCliArgs,
): Promise<Result<CmdSetupSuccess, string>> {
const readResult = await readWorkflowRegistry(storageRoot);
if (!readResult.ok) {
setupLog("W8JH4Q2K", `read workflow registry failed: ${readResult.error.message}`);
return err(readResult.error.message);
}
const current = readResult.value;
const merged = mergeWorkflowConfig(current.config, input);
if (!merged.ok) {
return merged;
}
const nextConfig = merged.value;
const nextRegistry = {
config: nextConfig,
workflows: current.workflows,
};
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
if (!written.ok) {
setupLog("M2NB5VX9", `write workflow registry failed: ${written.error.message}`);
return err(written.error.message);
}
const registryPath = workflowRegistryPath(storageRoot);
let initWorkspaceRootPath: string | null = null;
if (input.initWorkspaceName !== null) {
const initResult = await cmdInitWorkspace(process.cwd(), input.initWorkspaceName);
if (!initResult.ok) {
setupLog("T7QC4HWP", `init workspace failed: ${initResult.error}`);
return err(initResult.error);
}
initWorkspaceRootPath = initResult.value.rootPath;
}
return ok({
registryPath,
provider: input.provider,
defaultModel: input.defaultModel,
maxDepth: nextConfig.maxDepth,
supervisorInterval: nextConfig.supervisorInterval,
initWorkspaceRootPath,
});
}
export function printSetupSummary(result: CmdSetupSuccess): void {
printCliLine(`wrote registry: ${result.registryPath}`);
printCliLine(`provider "${result.provider}" (baseUrl + apiKey updated)`);
printCliLine(`config.models.default = "${result.defaultModel}"`);
printCliLine(`maxDepth=${result.maxDepth}, supervisorInterval=${result.supervisorInterval}`);
if (result.initWorkspaceRootPath !== null) {
printCliLine(`initialized workflow workspace at ${result.initWorkspaceRootPath}`);
}
}
@@ -0,0 +1,23 @@
/** Parsed non-interactive `setup` CLI arguments (all fields required for agent mode). */
export type SetupCliArgs = {
provider: string;
baseUrl: string;
apiKey: string;
defaultModel: string;
initWorkspaceName: string | null;
};
export type PresetProvider = {
name: string;
label: string;
baseUrl: string;
};
export type CmdSetupSuccess = {
registryPath: string;
provider: string;
defaultModel: string;
maxDepth: number;
supervisorInterval: number;
initWorkspaceRootPath: string | null;
};
@@ -1,4 +1,4 @@
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { END } from "@uncaged/workflow-runtime";
@@ -6,6 +6,21 @@ import { getGlobalCasDir } from "@uncaged/workflow-util";
import { resolveThreadRecord } from "../../thread-scan.js";
async function readParentStateFromStartNode(
cas: { get(hash: string): Promise<string | null> },
startHash: string,
): Promise<string | null> {
const yamlText = await cas.get(startHash);
if (yamlText === null) {
return null;
}
const parsed = parseCasThreadNode(yamlText);
if (parsed === null || parsed.kind !== "start") {
return null;
}
return parsed.node.payload.parentState;
}
export async function cmdThreadShow(
storageRoot: string,
threadId: string,
@@ -19,7 +34,15 @@ export async function cmdThreadShow(
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
const chronological = [...frames].reverse();
const steps: Array<{ role: string; hash: string; timestamp: number; content: string }> = [];
const parentState = await readParentStateFromStartNode(cas, resolved.start);
const steps: Array<{
role: string;
hash: string;
timestamp: number;
content: string;
childThread: string | null;
}> = [];
for (const fr of chronological) {
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
continue;
@@ -33,6 +56,7 @@ export async function cmdThreadShow(
payloadText !== null
? payloadText
: `(content not in CAS; contentHash=${fr.payload.content})`,
childThread: fr.payload.childThread,
});
}
@@ -41,6 +65,7 @@ export async function cmdThreadShow(
bundleHash: resolved.bundleHash,
head: resolved.head,
start: resolved.start,
parentState,
source: resolved.source,
steps,
};
+91 -17
View File
@@ -54,8 +54,9 @@ function formatSkillCli(): string {
const commandSections: string[] = [];
for (const group of groups) {
const rows = group.commands.map((cmd) => {
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
return `| \`${group.name}${namePart}\` | ${args} | ${cmd.description} |`;
});
commandSections.push(
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
@@ -182,32 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
\`\`\`typescript
// Required exports
// Required named exports (no default export)
export const descriptor: WorkflowDescriptor;
export const run: WorkflowRun;
export const run: WorkflowFn;
\`\`\`
## WorkflowDescriptor
Defines the workflow's metadata and role sequence:
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
\`\`\`typescript
type WorkflowDescriptor = {
name: string; // verb-first kebab-case, e.g. "solve-issue"
description: string; // one-line summary
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
description: string;
roles: Record<string, {
description: string;
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
}>;
graph: {
edges: Array<{
from: string; // role name, or "__start__"
to: string; // role name, or "__end__"
condition: string; // e.g. "FALLBACK"
conditionDescription?: string | null;
}>;
};
};
\`\`\`
## WorkflowRun
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
The main function that creates and returns a moderator:
## WorkflowFn
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
## ModeratorTable
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
\`\`\`typescript
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
const table: ModeratorTable<MyMeta> = {
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
firstRole: [{ condition: "FALLBACK", role: END }],
};
\`\`\`
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
## AdapterFn / AdapterBinding
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
\`\`\`typescript
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
\`\`\`
## Role Definition
@@ -226,15 +258,16 @@ Each role has:
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + moderator + descriptor)
# 2. Write your template (roles + ModeratorTable + definition)
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
# 3. Build the ESM bundle
bun run build
# 4. Build the ESM bundle
bun run bundle # uses scripts/bundle.ts
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 5. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
# 5. Test
# 6. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
@@ -242,5 +275,46 @@ uncaged-workflow live --latest
## Versioning
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
## Pitfalls
### Lazy initialization is mandatory
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
\`\`\`typescript
// ❌ WRONG — breaks register
const provider = { apiKey: process.env.MY_KEY! };
const adapter = createAdapter(provider);
// ✅ CORRECT — only reads env when run() is called
function createLazyAdapter(): AdapterFn {
let cached: Provider | null = null;
return (prompt, schema) => {
return async (ctx, runtime) => {
if (!cached) cached = { apiKey: process.env.MY_KEY! };
// ... use cached provider
};
};
}
\`\`\`
### Bundle import restrictions
The bundle validator only allows these import specifiers:
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
- \`@uncaged/workflow-*\` packages
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
### No default exports
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
### Single-file ESM
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
`;
}
@@ -4,6 +4,7 @@ import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
describe("validateCursorAgentConfig", () => {
test("accepts valid config with explicit workspace", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
@@ -14,6 +15,7 @@ describe("validateCursorAgentConfig", () => {
test("accepts valid config with null workspace and llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
@@ -22,8 +24,23 @@ describe("validateCursorAgentConfig", () => {
expect(r.ok).toBe(true);
});
test("rejects non-absolute command", () => {
const r = validateCursorAgentConfig({
command: "cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("absolute path");
}
});
test("rejects empty workspace string", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "",
@@ -37,6 +54,7 @@ describe("validateCursorAgentConfig", () => {
test("rejects null workspace without llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
@@ -50,6 +68,7 @@ describe("validateCursorAgentConfig", () => {
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: -1,
workspace: "/tmp/test-project",
@@ -62,6 +81,7 @@ describe("validateCursorAgentConfig", () => {
describe("createCursorAgent", () => {
test("returns an AgentFn with explicit workspace", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
@@ -72,6 +92,7 @@ describe("createCursorAgent", () => {
test("returns an AgentFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
@@ -80,25 +101,25 @@ describe("createCursorAgent", () => {
expect(typeof agent).toBe("function");
});
test("throws on invalid config at construction", () => {
expect(() =>
createCursorAgent({
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
}),
).toThrow();
test("defers validation to call time (invalid config does not throw at construction)", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
test("throws when null workspace without llmProvider", () => {
expect(() =>
createCursorAgent({
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
}),
).toThrow();
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.3.1",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -58,13 +58,13 @@ export async function extractWorkspacePath(
});
if (!result.ok) {
logger("V3KM8QWP", `workspace extraction failed: ${result.error}`);
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
return null;
}
const workspace = result.value.workspace.trim();
if (!workspace.startsWith("/")) {
logger("V3KM8QWP", `workspace extraction returned non-absolute path: ${workspace}`);
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
return null;
}
+6 -6
View File
@@ -30,16 +30,16 @@ function resolveCursorModel(model: string | null): string {
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return async (ctx) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
let workspace: string;
if (config.workspace !== null) {
@@ -71,7 +71,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
"--trust",
"--force",
];
const run = await spawnCli("cursor-agent", args, {
const run = await spawnCli(config.command, args, {
cwd: workspace,
timeoutMs,
});
@@ -1,6 +1,8 @@
import type { LlmProvider } from "@uncaged/workflow-protocol";
export type CursorAgentConfig = {
/** Absolute path to the cursor-agent CLI binary. */
command: string;
model: string | null;
timeout: number;
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
@@ -1,8 +1,13 @@
import { isAbsolute } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type { CursorAgentConfig } from "./types.js";
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
if (!isAbsolute(config.command)) {
return err("command must be an absolute path to the cursor-agent CLI binary");
}
if (config.workspace !== null && config.workspace.length === 0) {
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
}
@@ -4,14 +4,28 @@ import { createHermesAgent, validateHermesAgentConfig } from "../src/index.js";
describe("validateHermesAgentConfig", () => {
test("accepts valid config", () => {
const r = validateHermesAgentConfig({
command: "/usr/local/bin/hermes",
model: null,
timeout: null,
});
expect(r.ok).toBe(true);
});
test("rejects non-absolute command", () => {
const r = validateHermesAgentConfig({
command: "hermes",
model: null,
timeout: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("absolute path");
}
});
test("rejects negative timeout", () => {
const r = validateHermesAgentConfig({
command: "/usr/local/bin/hermes",
model: null,
timeout: -5,
});
@@ -23,10 +37,11 @@ describe("validateHermesAgentConfig", () => {
});
describe("createHermesAgent", () => {
test("returns an AgentFn", () => {
test("returns an AgentFn even with invalid config (validation deferred to call)", () => {
const agent = createHermesAgent({
command: "/usr/local/bin/hermes",
model: null,
timeout: null,
timeout: -5,
});
expect(typeof agent).toBe("function");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.3.1",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+6 -6
View File
@@ -26,14 +26,14 @@ function throwHermesSpawnError(error: SpawnCliError): never {
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const timeoutMs = config.timeout;
return async (ctx) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"chat",
@@ -47,7 +47,7 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
if (config.model !== null) {
args.push("--model", config.model);
}
const run = await spawnCli("hermes", args, {
const run = await spawnCli(config.command, args, {
cwd: null,
timeoutMs,
});
@@ -1,4 +1,6 @@
export type HermesAgentConfig = {
/** Absolute path to the hermes CLI binary. */
command: string;
model: string | null;
timeout: number | null;
};
@@ -1,8 +1,13 @@
import { isAbsolute } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-runtime";
import type { HermesAgentConfig } from "./types.js";
export function validateHermesAgentConfig(config: HermesAgentConfig): Result<void, string> {
if (!isAbsolute(config.command)) {
return err("command must be an absolute path to the hermes CLI binary");
}
if (config.timeout !== null && config.timeout < 0) {
return err("timeout must be null or a non-negative number (milliseconds)");
}
@@ -10,8 +10,10 @@ function makeCtx(userContent: string): AgentContext {
content: userContent,
meta: {},
timestamp: 1,
parentState: null,
},
depth: 0,
bundleHash: "TESTHASH00001",
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-llm",
"version": "0.3.1",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -0,0 +1,208 @@
import { describe, expect, test } from "bun:test";
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
import { ok } from "@uncaged/workflow-protocol";
import { START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
import * as z from "zod/v4";
import { createReactAdapter } from "../src/create-react-adapter.js";
import type { ReactAdapterConfig } from "../src/types.js";
// ── Helpers ─────────────────────────────────────────────────────────
function makeThread(prompt: string): ThreadContext {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: {
role: START,
content: prompt,
meta: {},
timestamp: Date.now(),
parentState: null,
},
steps: [],
};
}
const STUB_RUNTIME: WorkflowRuntime = {
cas: {
put: async (_content: string) => "STUBHASH",
get: async (_hash: string) => null,
delete: async (_hash: string) => {},
list: async () => [],
},
extract: async (_schema, _contentHash) => ({
meta: {},
contentPayload: "",
refs: [],
}),
};
const TEST_SCHEMA = z.object({
summary: z.string(),
score: z.number(),
}).meta({ title: "resolve", description: "Submit the final result." });
function makeChatResponse(content: string | null, toolCalls: unknown[] | null): string {
const message: Record<string, unknown> = { role: "assistant" };
if (content !== null) {
message.content = content;
}
if (toolCalls !== null) {
message.tool_calls = toolCalls;
}
return JSON.stringify({ choices: [{ message }] });
}
function makeToolCallResponse(name: string, args: Record<string, unknown>, id: string): string {
return makeChatResponse(null, [
{
id,
type: "function",
function: { name, arguments: JSON.stringify(args) },
},
]);
}
// ── Tests ───────────────────────────────────────────────────────────
describe("createReactAdapter", () => {
test("direct resolve: LLM immediately calls resolve tool with valid args", async () => {
const llm: LlmFn = async (_input) => {
return ok(makeToolCallResponse("resolve", { summary: "done", score: 42 }, "call_1"));
};
const config: ReactAdapterConfig = {
llm,
tools: [],
toolHandler: async () => "unused",
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "done", score: 42 });
expect(result.childThread).toBeNull();
});
test("tool call then resolve: LLM calls user tool first, then resolves", async () => {
let callCount = 0;
const llm: LlmFn = async (_input) => {
callCount += 1;
if (callCount === 1) {
return ok(makeToolCallResponse("search", { query: "test" }, "call_1"));
}
return ok(makeToolCallResponse("resolve", { summary: "found it", score: 99 }, "call_2"));
};
const searchTool: ToolDefinition = {
type: "function",
function: {
name: "search",
description: "Search for information",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
},
};
const toolResults: string[] = [];
const config: ReactAdapterConfig = {
llm,
tools: [searchTool],
toolHandler: async (name, args) => {
toolResults.push(`${name}:${args}`);
return "search result: found the answer";
},
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "found it", score: 99 });
expect(toolResults).toHaveLength(1);
expect(toolResults[0]).toContain("search:");
});
test("plain JSON response accepted", async () => {
const llm: LlmFn = async (_input) => {
return ok(makeChatResponse(JSON.stringify({ summary: "plain", score: 7 }), null));
};
const config: ReactAdapterConfig = {
llm,
tools: [],
toolHandler: async () => "unused",
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "plain", score: 7 });
});
test("schema validation failure + retry: invalid args then valid args", async () => {
let callCount = 0;
const llm: LlmFn = async (_input) => {
callCount += 1;
if (callCount === 1) {
// Invalid: score should be number, not string
return ok(makeToolCallResponse("resolve", { summary: "bad", score: "not-a-number" }, "call_1"));
}
return ok(makeToolCallResponse("resolve", { summary: "fixed", score: 10 }, "call_2"));
};
const config: ReactAdapterConfig = {
llm,
tools: [],
toolHandler: async () => "unused",
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "fixed", score: 10 });
expect(callCount).toBe(2);
});
test("max rounds exceeded: throws error", async () => {
const searchTool: ToolDefinition = {
type: "function",
function: {
name: "search",
description: "Search",
parameters: { type: "object", properties: {}, required: [] },
},
};
const llm: LlmFn = async (_input) => {
// Always call search, never resolve
return ok(makeToolCallResponse("search", {}, "call_n"));
};
const config: ReactAdapterConfig = {
llm,
tools: [searchTool],
toolHandler: async () => "still searching...",
maxRounds: 3,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
await expect(roleFn(makeThread("test task"), STUB_RUNTIME)).rejects.toThrow(
"max_react_rounds_exceeded",
);
});
});
@@ -0,0 +1,101 @@
import { describe, test, expect, afterAll } from "bun:test";
import { readFileTool, writeFileTool, patchFileTool, shellExecTool } from "../src/tools/index.js";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readFileSync, unlinkSync, mkdirSync } from "node:fs";
import { randomBytes } from "node:crypto";
const TMP_DIR = join(tmpdir(), `tools-test-${randomBytes(4).toString("hex")}`);
mkdirSync(TMP_DIR, { recursive: true });
const tmpFile = (name: string) => join(TMP_DIR, name);
const cleanupFiles: string[] = [];
afterAll(() => {
for (const f of cleanupFiles) {
try { unlinkSync(f); } catch { /* ignore */ }
}
try { unlinkSync(TMP_DIR); } catch { /* ignore */ }
});
describe("read_file", () => {
test("reads file with line numbers", async () => {
const p = tmpFile("read-test.txt");
cleanupFiles.push(p);
const content = "line1\nline2\nline3\n";
require("node:fs").writeFileSync(p, content);
const result = await readFileTool.handler(JSON.stringify({ path: p, offset: null, limit: null }));
expect(result).toContain("1|line1");
expect(result).toContain("2|line2");
expect(result).toContain("3|line3");
});
test("reads with offset and limit", async () => {
const p = tmpFile("read-test2.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "a\nb\nc\nd\ne\n");
const result = await readFileTool.handler(JSON.stringify({ path: p, offset: 2, limit: 2 }));
expect(result).toBe("2|b\n3|c");
});
test("returns error for missing file", async () => {
const result = await readFileTool.handler(JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }));
expect(result).toContain("Error:");
});
});
describe("write_file", () => {
test("writes file and creates dirs", async () => {
const p = tmpFile("sub/write-test.txt");
cleanupFiles.push(p);
const result = await writeFileTool.handler(JSON.stringify({ path: p, content: "hello world" }));
expect(result).toContain("11 bytes");
expect(readFileSync(p, "utf-8")).toBe("hello world");
});
});
describe("patch_file", () => {
test("patches file content", async () => {
const p = tmpFile("patch-test.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "foo bar baz");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "bar", new_string: "qux" }));
expect(result).toContain("Successfully");
expect(readFileSync(p, "utf-8")).toBe("foo qux baz");
});
test("errors on not found", async () => {
const p = tmpFile("patch-test2.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "foo");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }));
expect(result).toContain("not found");
});
test("errors on non-unique match", async () => {
const p = tmpFile("patch-test3.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "aaa bbb aaa");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "aaa", new_string: "ccc" }));
expect(result).toContain("not unique");
});
});
describe("shell_exec", () => {
test("runs echo", async () => {
const result = await shellExecTool.handler(JSON.stringify({ command: "echo hello", timeout: null }));
expect(result.trim()).toBe("hello");
});
test("handles timeout", async () => {
const result = await shellExecTool.handler(JSON.stringify({ command: "sleep 10", timeout: 1 }));
expect(result).toContain("timed out");
});
});
@@ -0,0 +1,27 @@
{
"name": "@uncaged/workflow-agent-react",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
},
"devDependencies": {
"zod": "^4.0.0"
},
"peerDependencies": {
"zod": "^4.0.0"
}
}
@@ -0,0 +1,69 @@
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-protocol";
import { createThreadReactor } from "@uncaged/workflow-reactor";
import { buildThreadInput } from "@uncaged/workflow-util-agent";
import * as z from "zod/v4";
import type { ReactAdapterConfig } from "./types.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): Record<string, unknown> {
const { $schema: _drop, ...rest } = json;
return rest;
}
function readToolName(parametersSchema: Record<string, unknown>): string {
const title = parametersSchema.title;
if (typeof title === "string" && title.trim().length > 0) {
return title.trim();
}
return "resolve";
}
function readToolDescription(parametersSchema: Record<string, unknown>): string {
const d = parametersSchema.description;
if (typeof d === "string" && d.trim().length > 0) {
return d.trim();
}
return "Submit the final structured result.";
}
export function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const reactor = createThreadReactor<ThreadContext>({
llm: config.llm,
staticTools: config.tools,
structuredToolFromSchema: (s) => {
const rawJsonSchema = z.toJSONSchema(s) as Record<string, unknown>;
const parameters = stripJsonSchemaMeta(rawJsonSchema);
const name = readToolName(parameters);
return {
name,
tool: {
type: "function" as const,
function: {
name,
description: readToolDescription(parameters),
parameters,
},
},
};
},
systemPromptForStructuredTool: (_name) => prompt,
toolHandler: async (call, _thread) => {
return config.toolHandler(call.function.name, call.function.arguments);
},
maxRounds: config.maxRounds,
});
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const input = await buildThreadInput(ctx);
const result = await reactor({ thread: ctx, input, schema });
if (!result.ok) throw new Error(result.error);
return { meta: result.value, childThread: null };
};
};
}
@@ -0,0 +1,4 @@
export { createReactAdapter } from "./create-react-adapter.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
export { defaultTools, defaultToolHandler } from "./tools/index.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
@@ -0,0 +1,16 @@
import type { ToolDefinition } from "@uncaged/workflow-reactor";
import type { ToolEntry } from "./types.js";
import { readFileTool } from "./read-file.js";
import { writeFileTool } from "./write-file.js";
import { patchFileTool } from "./patch-file.js";
import { shellExecTool } from "./shell-exec.js";
const ALL_TOOLS: ToolEntry[] = [readFileTool, writeFileTool, patchFileTool, shellExecTool];
export const defaultTools: readonly ToolDefinition[] = ALL_TOOLS.map((t) => t.definition);
export async function defaultToolHandler(name: string, args: string): Promise<string> {
const entry = ALL_TOOLS.find((t) => t.definition.function.name === name);
if (!entry) return `Unknown tool: ${name}`;
return entry.handler(args);
}
@@ -0,0 +1,6 @@
export { readFileTool } from "./read-file.js";
export { writeFileTool } from "./write-file.js";
export { patchFileTool } from "./patch-file.js";
export { shellExecTool } from "./shell-exec.js";
export { defaultTools, defaultToolHandler } from "./defaults.js";
export type { ToolEntry, ToolHandler } from "./types.js";
@@ -0,0 +1,40 @@
import { readFile, writeFile } from "node:fs/promises";
import type { ToolEntry } from "./types.js";
export const patchFileTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "patch_file",
description: "Find and replace a string in a file (first occurrence only).",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
old_string: { type: "string", description: "Text to find" },
new_string: { type: "string", description: "Replacement text" },
},
required: ["path", "old_string", "new_string"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; old_string: string; new_string: string };
const content = await readFile(parsed.path, "utf-8");
const firstIdx = content.indexOf(parsed.old_string);
if (firstIdx === -1) {
return `Error: old_string not found in ${parsed.path}`;
}
const secondIdx = content.indexOf(parsed.old_string, firstIdx + 1);
if (secondIdx !== -1) {
return `Error: old_string is not unique in ${parsed.path} (found multiple occurrences)`;
}
const updated = content.slice(0, firstIdx) + parsed.new_string + content.slice(firstIdx + parsed.old_string.length);
await writeFile(parsed.path, updated);
return `Successfully patched ${parsed.path}`;
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import type { ToolEntry } from "./types.js";
export const readFileTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "read_file",
description: "Read a text file and return lines with line numbers.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file to read" },
offset: { type: ["number", "null"], description: "Start line number (1-indexed, default: 1)" },
limit: { type: ["number", "null"], description: "Max lines to read (default: all)" },
},
required: ["path"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; offset: number | null; limit: number | null };
const content = await readFile(parsed.path, "utf-8");
const allLines = content.split("\n");
const offset = parsed.offset ?? 1;
const start = Math.max(0, offset - 1);
const end = parsed.limit != null ? Math.min(allLines.length, start + parsed.limit) : allLines.length;
const lines = allLines.slice(start, end);
return lines.map((line, i) => `${start + i + 1}|${line}`).join("\n");
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,45 @@
import { execSync } from "node:child_process";
import type { ToolEntry } from "./types.js";
const MAX_OUTPUT = 10000;
export const shellExecTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "shell_exec",
description: "Execute a shell command and return stdout + stderr.",
parameters: {
type: "object",
properties: {
command: { type: "string", description: "Shell command to run" },
timeout: { type: ["number", "null"], description: "Timeout in seconds (default: 30)" },
},
required: ["command"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { command: string; timeout: number | null };
const timeoutMs = (parsed.timeout ?? 30) * 1000;
const output = execSync(parsed.command, {
encoding: "utf-8",
timeout: timeoutMs,
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: MAX_OUTPUT * 2,
});
return output.length > MAX_OUTPUT ? `${output.slice(0, MAX_OUTPUT)}\n...(truncated)` : output;
} catch (err: unknown) {
if (err && typeof err === "object" && "status" in err && (err as { status: unknown }).status === null) {
return "Error: command timed out";
}
if (err && typeof err === "object" && "stderr" in err) {
const e = err as { stderr: string; stdout: string; status: number };
const combined = `${e.stdout ?? ""}${e.stderr ?? ""}`;
return combined.length > MAX_OUTPUT ? `${combined.slice(0, MAX_OUTPUT)}\n...(truncated)` : combined || `Error: command exited with status ${e.status}`;
}
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,8 @@
import type { ToolDefinition } from "@uncaged/workflow-reactor";
export type ToolHandler = (args: string) => Promise<string>;
export type ToolEntry = {
definition: ToolDefinition;
handler: ToolHandler;
};
@@ -0,0 +1,32 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { ToolEntry } from "./types.js";
export const writeFileTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "write_file",
description: "Write content to a file, creating parent directories as needed.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path to write" },
content: { type: "string", description: "File content" },
},
required: ["path", "content"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; content: string };
await mkdir(dirname(parsed.path), { recursive: true });
const buf = Buffer.from(parsed.content, "utf-8");
await writeFile(parsed.path, buf);
return `Successfully wrote ${buf.length} bytes to ${parsed.path}`;
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,10 @@
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
export type ReactToolHandler = (name: string, args: string) => Promise<string>;
export type ReactAdapterConfig = {
llm: LlmFn;
tools: readonly ToolDefinition[];
toolHandler: ReactToolHandler;
maxRounds: number;
};
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-reactor" },
{ "path": "../workflow-util-agent" }
]
}
@@ -14,6 +14,7 @@ function payload(
ancestors: partial.ancestors ?? [],
compact: partial.compact ?? null,
timestamp: partial.timestamp ?? 0,
childThread: partial.childThread ?? null,
};
}
@@ -62,4 +63,32 @@ describe("collectRefs", () => {
);
expect(refs).toEqual(["S2", "C2"]);
});
test("includes childThread hash when childThread is non-null", () => {
const refs = collectRefs(
payload({
role: "developer",
start: "S3",
content: "C3",
ancestors: ["A3"],
compact: null,
childThread: "CHILDEND000000000000001",
}),
);
expect(refs).toEqual(["S3", "C3", "A3", "CHILDEND000000000000001"]);
});
test("does not include childThread when childThread is null", () => {
const refs = collectRefs(
payload({
role: "developer",
start: "S4",
content: "C4",
ancestors: [],
compact: null,
childThread: null,
}),
);
expect(refs).toEqual(["S4", "C4"]);
});
});
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stringify } from "yaml";
import { createCasStore } from "../src/cas.js";
import { parseCasThreadNode, putStartNode, putStateNode } from "../src/nodes.js";
describe("putStartNode — parentState in refs", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "wf-cas-nodes-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
test("refs contains only promptHash when parentState is null", async () => {
const cas = createCasStore(join(dir, "cas"));
const promptHash = await cas.put("hello");
const startHash = await putStartNode(
cas,
{ name: "demo", hash: "BUNDLEAAAAAAAAA", depth: 0, parentState: null },
promptHash,
);
const blob = await cas.get(startHash);
expect(blob).not.toBeNull();
const parsed = parseCasThreadNode(blob ?? "");
expect(parsed).not.toBeNull();
expect(parsed?.kind).toBe("start");
if (parsed?.kind !== "start") return;
expect(parsed.node.refs).toEqual([promptHash]);
expect(parsed.node.payload.parentState).toBeNull();
});
test("refs contains [promptHash, parentStateHash] when parentState is set", async () => {
const cas = createCasStore(join(dir, "cas"));
const parentStateHash = await cas.put("fake-parent-state");
const promptHash = await cas.put("child-prompt");
const startHash = await putStartNode(
cas,
{ name: "develop", hash: "BUNDLEBBBBBBBBB", depth: 1, parentState: parentStateHash },
promptHash,
);
const blob = await cas.get(startHash);
expect(blob).not.toBeNull();
const parsed = parseCasThreadNode(blob ?? "");
expect(parsed).not.toBeNull();
expect(parsed?.kind).toBe("start");
if (parsed?.kind !== "start") return;
expect(parsed.node.refs).toEqual([promptHash, parentStateHash]);
expect(parsed.node.payload.parentState).toBe(parentStateHash);
});
});
describe("putStateNode — childThread in refs", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "wf-cas-nodes-state-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
test("refs does not include childThread when childThread is null", async () => {
const cas = createCasStore(join(dir, "cas"));
const startHash = await cas.put("start");
const contentHash = await cas.put("content");
const stateHash = await putStateNode(cas, {
role: "planner",
meta: {},
start: startHash,
content: contentHash,
ancestors: [],
compact: null,
timestamp: 1000,
childThread: null,
});
const blob = await cas.get(stateHash);
expect(blob).not.toBeNull();
const parsed = parseCasThreadNode(blob ?? "");
expect(parsed?.kind).toBe("state");
if (parsed?.kind !== "state") return;
expect(parsed.node.refs).not.toContain("anything-else");
expect(parsed.node.refs).toEqual([startHash, contentHash]);
expect(parsed.node.payload.childThread).toBeNull();
});
test("refs includes childThread hash when childThread is set", async () => {
const cas = createCasStore(join(dir, "cas"));
const startHash = await cas.put("start");
const contentHash = await cas.put("content");
const childEndHash = await cas.put("child-end-state");
const stateHash = await putStateNode(cas, {
role: "developer",
meta: { pr: 42 },
start: startHash,
content: contentHash,
ancestors: [],
compact: null,
timestamp: 2000,
childThread: childEndHash,
});
const blob = await cas.get(stateHash);
expect(blob).not.toBeNull();
const parsed = parseCasThreadNode(blob ?? "");
expect(parsed?.kind).toBe("state");
if (parsed?.kind !== "state") return;
expect(parsed.node.refs).toContain(childEndHash);
expect(parsed.node.payload.childThread).toBe(childEndHash);
});
});
describe("parseCasThreadNode — legacy node compatibility", () => {
test("start node without parentState field defaults to null", () => {
const yaml = stringify({
type: "start",
payload: { name: "demo", hash: "BUNDLEAAAAAAAAA", depth: 0 },
refs: ["PROMPTHASH00001"],
});
const parsed = parseCasThreadNode(yaml);
expect(parsed).not.toBeNull();
expect(parsed?.kind).toBe("start");
if (parsed?.kind !== "start") return;
expect(parsed.node.payload.parentState).toBeNull();
});
test("state node without childThread field defaults to null", () => {
const yaml = stringify({
type: "state",
payload: {
role: "planner",
meta: {},
start: "STARTHASH00001",
content: "CONTENTHASH0001",
ancestors: [],
compact: null,
timestamp: 1000,
},
refs: ["STARTHASH00001", "CONTENTHASH0001"],
});
const parsed = parseCasThreadNode(yaml);
expect(parsed).not.toBeNull();
expect(parsed?.kind).toBe("state");
if (parsed?.kind !== "state") return;
expect(parsed.node.payload.childThread).toBeNull();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.3.1",
"version": "0.3.11",
"type": "module",
"scripts": {
"test": "bun test"
@@ -9,5 +9,8 @@ export function collectRefs(payload: StateNode["payload"]): string[] {
if (payload.compact !== null) {
out.push(payload.compact);
}
if (payload.childThread !== null) {
out.push(payload.childThread);
}
return out;
}
+47 -3
View File
@@ -18,6 +18,10 @@ function isStartPayload(value: unknown): value is StartNodePayload {
if (!isRecord(value)) {
return false;
}
const parentState = value.parentState;
if (parentState !== undefined && parentState !== null && typeof parentState !== "string") {
return false;
}
return (
typeof value.name === "string" &&
typeof value.hash === "string" &&
@@ -25,6 +29,16 @@ function isStartPayload(value: unknown): value is StartNodePayload {
);
}
/** Normalizes a raw start payload, defaulting `parentState` to `null` for legacy nodes. */
function normalizeStartPayload(raw: StartNodePayload): StartNodePayload {
return {
name: raw.name,
hash: raw.hash,
depth: raw.depth,
parentState: raw.parentState ?? null,
};
}
function isStatePayload(value: unknown): value is StateNodePayload {
if (!isRecord(value)) {
return false;
@@ -41,6 +55,10 @@ function isStatePayload(value: unknown): value is StateNodePayload {
if (!isRecord(meta)) {
return false;
}
const childThread = value.childThread;
if (childThread !== undefined && childThread !== null && typeof childThread !== "string") {
return false;
}
return (
typeof value.role === "string" &&
typeof value.start === "string" &&
@@ -49,6 +67,20 @@ function isStatePayload(value: unknown): value is StateNodePayload {
);
}
/** Normalizes a raw state payload, defaulting `childThread` to `null` for legacy nodes. */
function normalizeStatePayload(raw: StateNodePayload): StateNodePayload {
return {
role: raw.role,
meta: raw.meta,
start: raw.start,
content: raw.content,
ancestors: raw.ancestors,
compact: raw.compact,
timestamp: raw.timestamp,
childThread: raw.childThread ?? null,
};
}
/** Parses a YAML CAS blob into a typed RFC v3 thread node (or legacy content layout with `children`). */
export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null {
let raw: unknown;
@@ -86,14 +118,22 @@ export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null
if (!isStartPayload(raw.payload)) {
return null;
}
const node: StartNode = { type: "start", payload: raw.payload, refs: [...refs] };
const node: StartNode = {
type: "start",
payload: normalizeStartPayload(raw.payload),
refs: [...refs],
};
return { kind: "start", node };
}
if (!isStatePayload(raw.payload)) {
return null;
}
const node: StateNode = { type: "state", payload: raw.payload, refs: [...refs] };
const node: StateNode = {
type: "state",
payload: normalizeStatePayload(raw.payload),
refs: [...refs],
};
return { kind: "state", node };
}
@@ -143,10 +183,14 @@ export async function putStartNode(
payload: StartNode["payload"],
promptHash: string,
): Promise<string> {
const refs = [promptHash];
if (payload.parentState !== null) {
refs.push(payload.parentState);
}
const node: StartNode = {
type: "start",
payload,
refs: [promptHash],
refs,
};
return store.put(serializeCasNode(node));
}
+2
View File
@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@xyflow/react": "^12.10.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
+49 -2
View File
@@ -66,8 +66,13 @@ export type AgentEndpoint = {
export type WorkflowSummary = {
name: string;
currentHash: string;
versions: number;
hash: string | null;
timestamp: number | null;
};
export type WorkflowHistoryEntry = {
hash: string;
timestamp: number;
};
export type ThreadSummary = {
@@ -104,6 +109,36 @@ export type WorkflowResultRecord = {
export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord;
export type WorkflowGraphEdge = {
from: string;
to: string;
condition: string;
conditionDescription: string | null;
};
export type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
export type WorkflowRoleDescriptor = {
description: string;
schema: Record<string, unknown>;
};
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph;
};
export type WorkflowDetail = {
name: string;
hash: string;
timestamp: number;
history: readonly WorkflowHistoryEntry[];
descriptor: WorkflowDescriptor | null;
};
// ── Gateway endpoints ───────────────────────────────────────────────
export function listAgents(): Promise<AgentEndpoint[]> {
@@ -117,6 +152,18 @@ export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSumma
return fetchJson(agentBase(agent), "/workflows");
}
export async function getWorkflowDetail(agent: string, name: string): Promise<WorkflowDetail> {
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`);
}
export async function getWorkflowDescriptor(
agent: string,
name: string,
): Promise<WorkflowDescriptor | null> {
const res = await getWorkflowDetail(agent, name);
return res.descriptor;
}
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads");
}
@@ -56,11 +56,11 @@ function StartCard({ record }: { record: ThreadStartRecord }) {
);
}
function RoleMessage({ record }: { record: RoleRecord }) {
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
const color = roleColor(record.role);
return (
<div
className="p-3 rounded-lg border text-sm"
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2">
@@ -114,12 +114,17 @@ function ResultCard({ record }: { record: WorkflowResultRecord }) {
);
}
export function RecordCard({ record }: { record: ThreadRecord }) {
type RecordCardProps = {
record: ThreadRecord;
highlighted: boolean;
};
export function RecordCard({ record, highlighted }: RecordCardProps) {
switch (record.type) {
case "thread-start":
return <StartCard record={record} />;
case "role":
return <RoleMessage record={record} />;
return <RoleMessage record={record} highlighted={highlighted} />;
case "workflow-result":
return <ResultCard record={record} />;
}
@@ -1,8 +1,17 @@
import { useEffect, useRef, useState } from "react";
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getThread,
getWorkflowDescriptor,
killThread,
pauseThread,
resumeThread,
type ThreadRecord,
type WorkflowDescriptor,
} from "../api.ts";
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
import { RecordCard } from "./record-card.tsx";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
type Props = {
agent: string;
@@ -10,11 +19,47 @@ type Props = {
onBack: () => void;
};
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
for (const r of records) {
if (r.type === "thread-start") return r.workflow;
}
return null;
}
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
const states = new Map<string, NodeState>();
const roleRecords = records.filter(
(r): r is Extract<ThreadRecord, { type: "role" }> => r.type === "role",
);
const hasResult = records.some((r) => r.type === "workflow-result");
for (let i = 0; i < roleRecords.length; i++) {
const role = roleRecords[i].role;
const isLast = i === roleRecords.length - 1;
states.set(role, !hasResult && isLast ? "active" : "completed");
}
if (roleRecords.length > 0) {
states.set("__start__", "completed");
}
if (hasResult) {
states.set("__end__", "completed");
for (const [k, v] of states) {
if (v === "active") states.set(k, "completed");
}
}
return states;
}
export function ThreadDetail({ agent, threadId, onBack }: Props) {
const sse = useSSE(agent, threadId);
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
const recordsEndRef = useRef<HTMLDivElement>(null);
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
const liveActive = sse.connected && !sse.completed;
const records = liveActive
@@ -23,6 +68,46 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
? data.records
: ([] as typeof sse.records);
const workflowName = useMemo(() => extractWorkflowName(records), [records]);
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
() =>
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(agent, workflowName),
[agent, workflowName],
);
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
const firstIndexByRole = useMemo(() => {
const m = new Map<string, number>();
for (let i = 0; i < records.length; i++) {
const r = records[i];
if (r.type === "role" && !m.has(r.role)) {
m.set(r.role, i);
}
}
return m;
}, [records]);
const handleGraphNodeClick = useCallback((roleName: string) => {
const el = firstCardByRoleRef.current.get(roleName);
if (el == null) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(roleName);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}, []);
useEffect(() => {
return () => {
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
};
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
useEffect(() => {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -95,20 +180,85 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
</p>
)}
{status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
{status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r, i) => (
<RecordCard key={`${threadId}-${i}`} record={r} />
))}
<div ref={recordsEndRef} aria-hidden />
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<div
className="shrink-0"
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 120px)",
alignSelf: "flex-start",
}}
>
<div
className="rounded-lg border h-full flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">
Workflow graph
{workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
)}
</span>
<span>
{descriptor.graph.edges.length} edge
{descriptor.graph.edges.length === 1 ? "" : "s"}
</span>
</div>
<div className="flex-1">
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={nodeStates}
onNodeClick={handleGraphNodeClick}
/>
</div>
</div>
</div>
)}
<div className="flex-1 min-w-0">
{status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
{status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r, i) => {
const key = `${threadId}-${i}`;
if (r.type === "role") {
const isFirstForRole = firstIndexByRole.get(r.role) === i;
const flash = highlightedRole === r.role;
return (
<div
key={key}
ref={(el) => {
if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
else firstCardByRoleRef.current.delete(r.role);
}}
>
<RecordCard record={r} highlighted={flash} />
</div>
);
}
return <RecordCard key={key} record={r} highlighted={false} />;
})}
<div ref={recordsEndRef} aria-hidden />
</div>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import {
BaseEdge,
EdgeLabelRenderer,
type EdgeProps,
getBezierPath,
getSmoothStepPath,
} from "@xyflow/react";
import type { ConditionEdgeData } from "./types.ts";
export function ConditionEdge(props: EdgeProps) {
const {
id,
source,
target,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
} = props;
const edgeData = data as ConditionEdgeData | undefined;
const isFallback = edgeData?.isFallback ?? false;
const isSelfLoop = source === target;
const [path, labelX, labelY] = isSelfLoop
? getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 20,
})
: getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
const strokeDasharray = isFallback ? "5 4" : undefined;
return (
<>
<BaseEdge
id={id}
path={path}
markerEnd={markerEnd}
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
/>
{edgeData && !isFallback && edgeData.condition !== "" && (
<EdgeLabelRenderer>
<div
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
color: "var(--color-text)",
}}
title={edgeData.conditionDescription ?? undefined}
>
{edgeData.condition}
</div>
</EdgeLabelRenderer>
)}
</>
);
}
@@ -0,0 +1,2 @@
export type { NodeState } from "./types.ts";
export { WorkflowGraph } from "./workflow-graph.tsx";
@@ -0,0 +1,69 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import type { RoleNodeData } from "./types.ts";
function borderColor(state: RoleNodeData["state"]): string {
switch (state) {
case "completed":
return "var(--color-success)";
case "active":
return "var(--color-accent)";
default:
return "var(--color-border)";
}
}
function stateIcon(state: RoleNodeData["state"]): string | null {
if (state === "completed") return "✓";
if (state === "active") return "●";
return null;
}
export function RoleNode(props: NodeProps) {
const data = props.data as RoleNodeData;
const icon = stateIcon(data.state);
const isActive = data.state === "active";
const handleStyle = {
background: "var(--color-text-muted)",
width: 6,
height: 6,
border: "none",
} as const;
return (
<div
className={`px-3 py-2 rounded-md border-2 text-xs font-medium cursor-pointer ${isActive ? "wf-node-pulse" : ""}`}
style={{
width: 180,
height: 60,
background: "var(--color-surface)",
borderColor: borderColor(data.state),
color: "var(--color-text)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
boxSizing: "border-box",
}}
title={data.description}
>
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
<div className="flex items-center gap-1.5 font-mono">
{icon !== null && (
<span
style={{
color: data.state === "active" ? "var(--color-accent)" : "var(--color-success)",
}}
>
{icon}
</span>
)}
<span className="truncate">{data.label}</span>
</div>
{data.description !== "" && (
<div className="text-[10px] truncate mt-0.5" style={{ color: "var(--color-text-muted)" }}>
{data.description}
</div>
)}
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
</div>
);
}
@@ -0,0 +1,57 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import type { TerminalNodeData } from "./types.ts";
function borderColor(state: TerminalNodeData["state"]): string {
switch (state) {
case "completed":
return "var(--color-success)";
case "active":
return "var(--color-accent)";
default:
return "var(--color-border)";
}
}
function bgColor(state: TerminalNodeData["state"]): string {
if (state === "completed") return "var(--color-success)";
if (state === "active") return "var(--color-accent)";
return "var(--color-surface)";
}
export function TerminalNode(props: NodeProps) {
const data = props.data as TerminalNodeData;
const isStart = data.kind === "start";
const isActive = data.state === "active";
const handleStyle = {
background: "var(--color-text-muted)",
width: 6,
height: 6,
border: "none",
} as const;
return (
<div
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""}`}
style={{
width: 40,
height: 40,
background: bgColor(data.state),
borderColor: borderColor(data.state),
color: data.state === "default" ? "var(--color-text-muted)" : "var(--color-bg)",
}}
title={isStart ? "Start" : "End"}
>
{isStart ? (
<Handle
type="source"
position={Position.Bottom}
style={handleStyle}
isConnectable={false}
/>
) : (
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
)}
{isStart ? "▶" : "■"}
</div>
);
}
@@ -0,0 +1,29 @@
import type { WorkflowGraphEdge } from "../../api.ts";
export type NodeState = "default" | "completed" | "active";
export type TerminalKind = "start" | "end";
export type RoleNodeData = {
label: string;
description: string;
state: NodeState;
[key: string]: unknown;
};
export type TerminalNodeData = {
kind: TerminalKind;
state: NodeState;
[key: string]: unknown;
};
export type ConditionEdgeData = {
condition: string;
conditionDescription: string | null;
isFallback: boolean;
[key: string]: unknown;
};
export type GraphInput = {
edges: readonly WorkflowGraphEdge[];
};
@@ -0,0 +1,127 @@
import Dagre from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import { useMemo } from "react";
import type { WorkflowGraphEdge } from "../../api.ts";
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
const START_ID = "__start__";
const END_ID = "__end__";
const ROLE_NODE_WIDTH = 180;
const ROLE_NODE_HEIGHT = 60;
const TERMINAL_NODE_SIZE = 40;
type LayoutInput = {
edges: readonly WorkflowGraphEdge[];
roles: Record<string, { description: string }>;
nodeStates: Map<string, NodeState>;
};
type LayoutResult = {
nodes: Node[];
edges: Edge[];
};
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
return ids;
}
function nodeSize(id: string): { width: number; height: number } {
if (id === START_ID || id === END_ID) {
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
}
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
}
function buildRoleNode(
id: string,
pos: { x: number; y: number },
roles: Record<string, { description: string }>,
state: NodeState,
): Node<RoleNodeData> {
const description = roles[id]?.description ?? "";
return {
id,
type: "role",
position: pos,
data: { label: id, description, state },
draggable: false,
};
}
function buildTerminalNode(
id: string,
pos: { x: number; y: number },
state: NodeState,
): Node<TerminalNodeData> {
return {
id,
type: "terminal",
position: pos,
data: { kind: id === START_ID ? "start" : "end", state },
draggable: false,
selectable: false,
};
}
function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
const isFallback = e.condition === "FALLBACK";
return {
id: edgeKey(e),
source: e.from,
target: e.to,
type: "condition",
data: {
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
},
};
}
export function useLayout(input: LayoutInput): LayoutResult {
return useMemo(() => {
const ids = collectNodeIds(input.edges);
const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
for (const id of ids) {
const size = nodeSize(id);
g.setNode(id, { width: size.width, height: size.height });
}
for (const e of input.edges) {
if (e.from === e.to) {
continue;
}
g.setEdge(e.from, e.to, {}, edgeKey(e));
}
Dagre.layout(g);
const nodes: Node[] = [];
for (const id of ids) {
const dagNode = g.node(id);
const size = nodeSize(id);
const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 };
const state = input.nodeStates.get(id) ?? "default";
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, pos, state));
} else {
nodes.push(buildRoleNode(id, pos, input.roles, state));
}
}
const edges: Edge[] = input.edges.map(buildEdge);
return { nodes, edges };
}, [input.edges, input.roles, input.nodeStates]);
}
@@ -0,0 +1,79 @@
import {
Background,
type EdgeTypes,
MarkerType,
type Node,
type NodeTypes,
type OnNodeClick,
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useMemo } from "react";
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
import { ConditionEdge } from "./condition-edge.tsx";
import { RoleNode } from "./role-node.tsx";
import { TerminalNode } from "./terminal-node.tsx";
import type { NodeState } from "./types.ts";
import { useLayout } from "./use-layout.ts";
type Props = {
graph: WorkflowGraphData;
roles: Record<string, { description: string }>;
nodeStates: Map<string, NodeState>;
onNodeClick: ((roleName: string) => void) | null;
};
const nodeTypes: NodeTypes = {
role: RoleNode,
terminal: TerminalNode,
};
const edgeTypes: EdgeTypes = {
condition: ConditionEdge,
};
function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node): void {
if (node.type !== "role") return;
onRoleClick(node.id);
}
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
const onNodeClickHandler: OnNodeClick | undefined =
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
const styledEdges = useMemo(
() =>
layout.edges.map((e) => ({
...e,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 14,
height: 14,
color: "var(--color-text)",
},
})),
[layout.edges],
);
return (
<ReactFlow
nodes={layout.nodes}
edges={styledEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClickHandler}
fitView
fitViewOptions={{ padding: 0.15 }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
colorMode="dark"
style={{ background: "var(--color-bg)" }}
>
<Background color="var(--color-border)" gap={20} size={1} />
</ReactFlow>
);
}
@@ -1,12 +1,168 @@
import { listWorkflows } from "../api.ts";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { WorkflowDetail } from "../api.ts";
import { getWorkflowDetail, listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
type Props = {
agent: string;
};
type DetailCacheEntry =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ok"; detail: WorkflowDetail };
function versionCount(detail: WorkflowDetail): number {
return detail.history.length + 1;
}
function ExpandedWorkflowBody({
cacheEntry,
staticNodeStates,
}: {
cacheEntry: DetailCacheEntry | undefined;
staticNodeStates: Map<string, NodeState>;
}) {
if (cacheEntry === undefined || cacheEntry.status === "loading") {
return (
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
Loading workflow details...
</p>
);
}
if (cacheEntry.status === "error") {
return (
<p className="text-sm py-2" style={{ color: "var(--color-error)" }}>
{cacheEntry.message}
</p>
);
}
const { detail } = cacheEntry;
const descriptor = detail.descriptor;
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
const vc = versionCount(detail);
return (
<div className="pt-3 space-y-3 border-t" style={{ borderColor: "var(--color-border)" }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{detail.name}
</p>
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
Hash
</p>
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
</div>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{vc} version{vc !== 1 ? "s" : ""}
</p>
<div>
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
Description
</p>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: descriptor !== null
? "—"
: "No descriptor available for this workflow version."}
</p>
</div>
{descriptor !== null && edgeCount > 0 ? (
<div
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}
>
<div
className="px-3 py-2 text-xs flex justify-between items-center"
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
>
<span className="font-mono">Workflow graph</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</div>
<div style={{ height: 300, width: "100%" }}>
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={staticNodeStates}
onNodeClick={null}
/>
</div>
</div>
) : null}
</div>
);
}
export function WorkflowList({ agent }: Props) {
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
() => new Map(),
);
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching agents
useEffect(() => {
setExpanded(new Set());
setDetailsByName(new Map());
}, [agent]);
const ensureDetailLoaded = useCallback(
(name: string) => {
setDetailsByName((prev) => {
const cur = prev.get(name);
if (cur !== undefined && (cur.status === "ok" || cur.status === "loading")) {
return prev;
}
return new Map(prev).set(name, { status: "loading" });
});
void (async () => {
try {
const detail = await getWorkflowDetail(agent, name);
setDetailsByName((prev) => {
const next = new Map(prev);
next.set(name, { status: "ok", detail });
return next;
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
setDetailsByName((prev) => {
const next = new Map(prev);
next.set(name, { status: "error", message });
return next;
});
}
})();
},
[agent],
);
function toggleExpanded(name: string) {
let shouldLoad = false;
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
return next;
}
next.add(name);
shouldLoad = true;
return next;
});
if (shouldLoad) {
ensureDetailLoaded(name);
}
}
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
@@ -21,26 +177,58 @@ export function WorkflowList({ agent }: Props) {
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
) : (
<div className="space-y-2">
{workflows.map((w) => (
<div
key={w.name}
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<span className="font-medium">{w.name}</span>
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{w.versions} version{w.versions !== 1 ? "s" : ""}
</span>
</div>
<code
className="text-xs mt-1 block font-mono"
style={{ color: "var(--color-accent)" }}
{workflows.map((w) => {
const isOpen = expanded.has(w.name);
return (
<div
key={w.name}
className="rounded-lg border overflow-hidden"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
{w.currentHash}
</code>
</div>
))}
<button
type="button"
onClick={() => toggleExpanded(w.name)}
className="w-full text-left p-4 flex items-start justify-between gap-3 hover:opacity-90"
style={{ color: "var(--color-text)" }}
aria-expanded={isOpen}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="text-xs font-mono"
style={{ color: "var(--color-text-muted)" }}
>
{isOpen ? "▼" : "▶"}
</span>
<span className="font-medium">{w.name}</span>
</div>
<code
className="text-xs mt-1 block font-mono truncate"
style={{ color: "var(--color-accent)" }}
>
{w.hash !== null ? w.hash : "—"}
</code>
{w.timestamp !== null ? (
<span
className="text-xs mt-1 block"
style={{ color: "var(--color-text-muted)" }}
>
Updated {new Date(w.timestamp).toLocaleString()}
</span>
) : null}
</div>
</button>
{isOpen ? (
<div className="px-4 pb-4">
<ExpandedWorkflowBody
cacheEntry={detailsByName.get(w.name)}
staticNodeStates={staticNodeStates}
/>
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
+30
View File
@@ -19,3 +19,33 @@ body {
color: var(--color-text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
@keyframes wf-node-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(124, 109, 240, 0.55);
}
50% {
box-shadow: 0 0 0 6px rgba(124, 109, 240, 0);
}
}
.wf-node-pulse {
animation: wf-node-pulse 1.6s ease-in-out infinite;
}
@keyframes wf-record-card-highlight {
0% {
border-color: var(--color-accent);
}
35% {
border-color: var(--color-accent);
}
100% {
border-color: var(--color-border);
}
}
.wf-record-card-highlight {
animation: wf-record-card-highlight 1.5s ease-out forwards;
}
@@ -35,6 +35,7 @@ function noLogger(): (tag: string, content: string) => void {
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
return {
depth: 0,
parentStateHash: null,
signal: new AbortController().signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
@@ -144,9 +145,9 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h1 = await runtime.cas.put("plan-text");
yield { role: "planner", contentHash: h1, meta: { plan: 1 }, refs: [h1] };
yield { role: "planner", contentHash: h1, meta: { plan: 1 }, refs: [h1], childThread: null };
const h2 = await runtime.cas.put("code-text");
yield { role: "coder", contentHash: h2, meta: { diff: "y" }, refs: [h2] };
yield { role: "coder", contentHash: h2, meta: { diff: "y" }, refs: [h2], childThread: null };
return { returnCode: 0, summary: "done" };
};
@@ -210,7 +211,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h = await runtime.cas.put("only-step");
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
yield { role: "only", contentHash: h, meta: {}, refs: [h], childThread: null };
return { returnCode: 0, summary: "completed" };
};
@@ -261,7 +262,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h = await runtime.cas.put("step");
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
yield { role: "only", contentHash: h, meta: {}, refs: [h], childThread: null };
return { returnCode: 0, summary: "done" };
};
@@ -46,6 +46,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
@@ -59,6 +60,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
ancestors: [],
compact: null,
timestamp: 1,
childThread: null,
} satisfies StateNodePayload);
const c2 = await putContentNodeWithRefs(cas, "c1", []);
@@ -70,6 +72,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
ancestors: [h1],
compact: null,
timestamp: 2,
childThread: null,
} satisfies StateNodePayload);
const ec = await putContentNodeWithRefs(cas, "", []);
@@ -81,6 +84,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
ancestors: [h1],
compact: null,
timestamp: 3,
childThread: null,
} satisfies StateNodePayload);
await upsertThreadEntry(bundleDir, "THREAD_AAAAAAA", {
@@ -0,0 +1,306 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasStore } from "@uncaged/workflow-cas";
import { createCasStore, parseCasThreadNode } from "@uncaged/workflow-cas";
import type { StartNode, StateNode } from "@uncaged/workflow-protocol";
import type {
RoleOutput,
ThreadContext,
WorkflowCompletion,
WorkflowFn,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { executeThread } from "../src/engine/engine.js";
import type { ExecuteThreadIo, ExecuteThreadOptions } from "../src/engine/types.js";
const TEST_REGISTRY_YAML = `config:
maxDepth: 3
supervisorInterval: 0
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`;
function noLogger(): (tag: string, content: string) => void {
return () => {};
}
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
return {
depth: 0,
parentStateHash: null,
signal: new AbortController().signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
forkContinuation: null,
replayTimestamps: null,
storageRoot: "/tmp/never",
...overrides,
};
}
async function setupStorage(): Promise<{
storageRoot: string;
casDir: string;
}> {
const storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-merkle-"));
await writeFile(join(storageRoot, "workflow.yaml"), TEST_REGISTRY_YAML, "utf8");
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
return { storageRoot, casDir };
}
async function loadStartNode(cas: CasStore, endHash: string): Promise<StartNode> {
const endBlob = await cas.get(endHash);
const endParsed = parseCasThreadNode(endBlob ?? "");
if (endParsed?.kind !== "state") throw new Error("expected state node");
const startBlob = await cas.get(endParsed.node.payload.start);
const startParsed = parseCasThreadNode(startBlob ?? "");
if (startParsed?.kind !== "start") throw new Error("expected start node");
return startParsed.node;
}
async function loadStateNode(cas: CasStore, hash: string): Promise<StateNode> {
const blob = await cas.get(hash);
const parsed = parseCasThreadNode(blob ?? "");
if (parsed?.kind !== "state") throw new Error("expected state node");
return parsed.node;
}
describe("Merkle call stack — cross-thread DAG linking (Phase 2)", () => {
let storageRoot: string;
let casDir: string;
beforeEach(async () => {
const setup = await setupStorage();
storageRoot = setup.storageRoot;
casDir = setup.casDir;
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
test("parentStateHash is written into child start node's parentState and refs", async () => {
const cas = createCasStore(casDir);
// biome-ignore lint/correctness/useYield: testing start-only path
const parentWf: WorkflowFn = async function* (
_thread: ThreadContext,
_runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
return { returnCode: 0, summary: "parent done" };
};
const parentResult = await executeThread(
parentWf,
"parent-wf",
{ prompt: "parent task", steps: [] },
makeOptions({ storageRoot }),
{
threadId: "P_THREAD_01",
hash: "PARENTHASH0001",
infoJsonlPath: join(storageRoot, "logs", "PARENTHASH0001", "P1.info.jsonl"),
cas,
},
noLogger(),
);
// biome-ignore lint/correctness/useYield: testing start-only path
const childWf: WorkflowFn = async function* (
_thread: ThreadContext,
_runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
return { returnCode: 0, summary: "child done" };
};
const childResult = await executeThread(
childWf,
"child-wf",
{ prompt: "child task", steps: [] },
makeOptions({ storageRoot, depth: 1, parentStateHash: parentResult.rootHash }),
{
threadId: "C_THREAD_01",
hash: "CHILDHASH00001",
infoJsonlPath: join(storageRoot, "logs", "CHILDHASH00001", "C1.info.jsonl"),
cas,
},
noLogger(),
);
const childStart = await loadStartNode(cas, childResult.rootHash);
expect(childStart.payload.parentState).toBe(parentResult.rootHash);
expect(childStart.refs).toContain(parentResult.rootHash);
});
test("childThread on parent state node points to child's final state and is in refs", async () => {
const cas = createCasStore(casDir);
const childFinalHash = "CHILD_FINAL_001";
const parentWf: WorkflowFn = async function* (
_thread: ThreadContext,
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h = await runtime.cas.put("developer output");
yield {
role: "developer",
contentHash: h,
meta: { action: "delegate" },
refs: [h],
childThread: childFinalHash,
};
return { returnCode: 0, summary: "parent complete" };
};
const result = await executeThread(
parentWf,
"parent-wf",
{ prompt: "parent task", steps: [] },
makeOptions({ storageRoot }),
{
threadId: "P_THREAD_02",
hash: "CTHREAD_TEST01",
infoJsonlPath: join(storageRoot, "logs", "CTHREAD_TEST01", "P2.info.jsonl"),
cas,
},
noLogger(),
);
const endNode = await loadStateNode(cas, result.rootHash);
const devStateHash = endNode.payload.ancestors[0] ?? "";
const devNode = await loadStateNode(cas, devStateHash);
expect(devNode.payload.role).toBe("developer");
expect(devNode.payload.childThread).toBe(childFinalHash);
expect(devNode.refs).toContain(childFinalHash);
});
test("parent state with no child has childThread: null", async () => {
const cas = createCasStore(casDir);
const wf: WorkflowFn = async function* (
_thread: ThreadContext,
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h = await runtime.cas.put("prep output");
yield { role: "preparer", contentHash: h, meta: {}, refs: [h], childThread: null };
return { returnCode: 0, summary: "done" };
};
const result = await executeThread(
wf,
"test-wf",
{ prompt: "task", steps: [] },
makeOptions({ storageRoot }),
{
threadId: "NULL_CT_01",
hash: "NULLCT_TEST001",
infoJsonlPath: join(storageRoot, "logs", "NULLCT_TEST001", "N1.info.jsonl"),
cas,
},
noLogger(),
);
const endNode = await loadStateNode(cas, result.rootHash);
const prepHash = endNode.payload.ancestors[0] ?? "";
const prepNode = await loadStateNode(cas, prepHash);
expect(prepNode.payload.childThread).toBeNull();
expect(prepNode.refs).not.toContain(null);
});
test("full bidirectional: child parentState is traversable to parent's context", async () => {
const cas = createCasStore(casDir);
const parentHash = "BIDIR_PARENT01";
const parentWf: WorkflowFn = async function* (
_thread: ThreadContext,
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h1 = await runtime.cas.put("preparation output");
yield {
role: "preparer",
contentHash: h1,
meta: { repoPath: "/test" },
refs: [h1],
childThread: null,
};
const h2 = await runtime.cas.put("developer output");
yield {
role: "developer",
contentHash: h2,
meta: { action: "code" },
refs: [h2],
childThread: "CHILD_END_HASH1",
};
return { returnCode: 0, summary: "all done" };
};
const observedHeads: string[] = [];
const opts = makeOptions({
storageRoot,
awaitAfterEachYield: async () => {
const bundleDir = join(storageRoot, "bundles", parentHash);
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
const parsed = JSON.parse(text) as Record<string, { head: string }>;
const head = parsed.BIDIR_T_001?.head ?? null;
if (head !== null) observedHeads.push(head);
},
});
await executeThread(
parentWf,
"bidir-wf",
{ prompt: "bidir test", steps: [] },
opts,
{
threadId: "BIDIR_T_001",
hash: parentHash,
infoJsonlPath: join(storageRoot, "logs", parentHash, "BD1.info.jsonl"),
cas,
},
noLogger(),
);
expect(observedHeads.length).toBe(2);
const preparerStateHash = observedHeads[0] ?? "";
// Execute child with parentState pointing to parent's preparer state
// biome-ignore lint/correctness/useYield: testing start-only path
const childWf: WorkflowFn = async function* (
_t: ThreadContext,
_r: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
return { returnCode: 0, summary: "child ok" };
};
const childResult = await executeThread(
childWf,
"bidir-child",
{ prompt: "child bidir", steps: [] },
makeOptions({ storageRoot, depth: 1, parentStateHash: preparerStateHash }),
{
threadId: "BIDIR_C_001",
hash: "BIDIR_CHILD001",
infoJsonlPath: join(storageRoot, "logs", "BIDIR_CHILD001", "BC1.info.jsonl"),
cas,
},
noLogger(),
);
// Upward traversal: child start → parentState → preparer state → meta.repoPath
const childStart = await loadStartNode(cas, childResult.rootHash);
expect(childStart.payload.parentState).toBe(preparerStateHash);
const parentPrep = await loadStateNode(cas, preparerStateHash);
expect(parentPrep.payload.meta.repoPath).toBe("/test");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-execute",
"version": "0.3.1",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
@@ -94,6 +94,7 @@ async function appendStateForStep(params: {
meta: Record<string, unknown>;
refs: readonly string[];
timestamp: number;
childThread: string | null;
}): Promise<{ stateHash: string; chain: ChainState }> {
const text = await getContentMerklePayload(params.cas, params.contentHash);
if (text === null) {
@@ -112,6 +113,7 @@ async function appendStateForStep(params: {
ancestors,
compact: null,
timestamp: params.timestamp,
childThread: params.childThread,
};
const stateHash = await putStateNode(params.cas, payload);
return {
@@ -137,6 +139,7 @@ async function appendEndState(params: {
ancestors,
compact: null,
timestamp: params.timestamp,
childThread: null,
};
return putStateNode(params.cas, payload);
}
@@ -329,6 +332,7 @@ async function driveWorkflowGenerator(params: {
meta: step.meta,
refs: step.refs,
timestamp: ts,
childThread: step.childThread ?? null,
});
chain = written_.chain;
await publishHead({ bundleDir, threadId, startHash, headHash: written_.stateHash });
@@ -439,6 +443,7 @@ export async function executeThread(
name: workflowName,
hash: io.hash,
depth: options.depth,
parentState: options.parentStateHash,
},
promptHash,
);
@@ -466,6 +471,7 @@ export async function executeThread(
meta: row.meta,
refs: row.refs,
timestamp: row.timestamp,
childThread: null,
});
chain = written.chain;
await publishHead({
@@ -487,11 +493,13 @@ export async function executeThread(
const thread: ThreadContext = {
threadId: io.threadId,
depth: options.depth,
bundleHash: io.hash,
start: {
role: START,
content: input.prompt,
meta: {},
timestamp: nowMs,
parentState: options.parentStateHash,
},
steps: input.steps.map((out, i) => ({
role: out.role,
@@ -144,6 +144,7 @@ async function payloadToRoleOutput(cas: CasStore, payload: StateNodePayload): Pr
contentHash: payload.content,
meta: payload.meta,
refs,
childThread: payload.childThread,
};
}
@@ -240,6 +241,7 @@ async function buildForkContinuation(params: {
ancestors: ancestorsMarker,
compact: null,
timestamp: Date.now(),
childThread: null,
};
const markerHash = await putStateNode(cas, markerPayload);
@@ -41,6 +41,8 @@ export type PrefilledDiskStep = {
export type ExecuteThreadOptions = {
/** Passed to the bundle thread context as `ThreadContext.depth`. */
depth: number;
/** Parent thread's head state hash at spawn time; `null` for top-level threads. */
parentStateHash: string | null;
signal: AbortSignal;
/** Invoked after each successful yield (and outer-loop checks); used for pause/resume. */
awaitAfterEachYield: () => Promise<void>;
@@ -72,11 +72,13 @@ function parseRoleOutputRecord(obj: Record<string, unknown>): RoleOutput | null
if (meta === null || typeof meta !== "object") {
return null;
}
const childThread = obj.childThread;
return {
role,
contentHash,
meta: meta as Record<string, unknown>,
refs: normalizeRefsField(obj.refs),
childThread: typeof childThread === "string" ? childThread : null,
};
}
@@ -497,6 +499,7 @@ async function main(): Promise<void> {
{ prompt: cmd.prompt, steps: cmd.steps },
{
...cmd.options,
parentStateHash: null,
signal: ac.signal,
awaitAfterEachYield: () => pauseGate.awaitAfterYield(),
forkSourceThreadId: cmd.forkSourceThreadId,
@@ -6,7 +6,7 @@ import {
getRegisteredWorkflow,
readWorkflowRegistry,
} from "@uncaged/workflow-register";
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
import type { AgentContext, AgentFn, AgentFnResult } from "@uncaged/workflow-runtime";
import {
createLogger,
generateUlid,
@@ -14,7 +14,7 @@ import {
getGlobalCasDir,
} from "@uncaged/workflow-util";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread } from "./engine/index.js";
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
@@ -37,6 +37,13 @@ function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | nul
return getDefaultWorkflowStorageRoot();
}
async function readParentHeadState(storageRoot: string, ctx: AgentContext): Promise<string | null> {
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
const index = await readThreadsIndex(bundleDir);
const entry = index[ctx.threadId] ?? null;
return entry !== null ? entry.head : null;
}
/**
* Returns an {@link AgentFn} that runs another registered workflow in a new thread,
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
@@ -45,30 +52,30 @@ export function workflowAsAgent(
workflowName: string,
options: WorkflowAsAgentOptions | null = null,
): AgentFn {
return async (ctx: AgentContext): Promise<string> => {
return async (ctx: AgentContext): Promise<AgentFnResult> => {
const nextDepth = ctx.depth + 1;
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
const registryResult = await readWorkflowRegistry(storageRoot);
if (!registryResult.ok) {
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
return { output: `ERROR: failed to read workflow registry: ${registryResult.error.message}`, childThread: null };
}
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) {
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
return { output: `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`, childThread: null };
}
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
if (entry === null) {
return `ERROR: workflow "${workflowName}" not found in registry`;
return { output: `ERROR: workflow "${workflowName}" not found in registry`, childThread: null };
}
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
if (!bundleExportsResult.ok) {
return `ERROR: ${bundleExportsResult.error}`;
return { output: `ERROR: ${bundleExportsResult.error}`, childThread: null };
}
const input = {
@@ -89,6 +96,8 @@ export function workflowAsAgent(
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
const signalNever = new AbortController();
const parentHeadState = await readParentHeadState(storageRoot, ctx);
try {
const result = await executeThread(
bundleExportsResult.value.run,
@@ -96,6 +105,7 @@ export function workflowAsAgent(
input,
{
depth: nextDepth,
parentStateHash: parentHeadState,
signal: signalNever.signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: ctx.threadId,
@@ -107,10 +117,11 @@ export function workflowAsAgent(
io,
logger,
);
return `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
const summary = `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
return { output: summary, childThread: result.rootHash };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return `ERROR: ${message}`;
return { output: `ERROR: ${message}`, childThread: null };
}
};
}
+5 -2
View File
@@ -1,8 +1,11 @@
{
"name": "@uncaged/workflow-gateway",
"version": "0.1.0",
"private": true,
"version": "0.3.11",
"type": "module",
"exports": {
".": "./src/index.ts",
"./ws-protocol": "./src/ws-protocol.ts"
},
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
@@ -0,0 +1,162 @@
/** One Durable Object instance per agent name; holds the reverse WebSocket from the agent CLI. */
import { DurableObject } from "cloudflare:workers";
import { parseWsRequestJson, parseWsResponseJson, type WsResponse } from "./ws-protocol.js";
type AgentSocketEnv = {
GATEWAY_SECRET: string;
};
export const AGENT_SOCKET_INTERNAL_STATUS_PATH = "/internal/agent-socket/status";
export const AGENT_SOCKET_INTERNAL_PROXY_PATH = "/internal/agent-socket/proxy";
const PROXY_TIMEOUT_MS = 30_000;
type PendingEntry = {
resolve: (r: Response) => void;
timer: ReturnType<typeof setTimeout>;
};
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function wsResponseToHttp(wr: WsResponse): Response {
const headers = new Headers();
for (const [k, v] of Object.entries(wr.headers)) {
headers.set(k, v);
}
return new Response(wr.body, { status: wr.status, headers });
}
export class AgentSocket extends DurableObject<AgentSocketEnv> {
private readonly pending = new Map<string, PendingEntry>();
private requireAuth(request: Request): Response | null {
const auth = request.headers.get("Authorization");
if (auth !== `Bearer ${this.env.GATEWAY_SECRET}`) {
return jsonResponse(401, { error: "unauthorized" });
}
return null;
}
private handleStatusGet(request: Request): Response {
const denied = this.requireAuth(request);
if (denied !== null) {
return denied;
}
const sockets = this.ctx.getWebSockets();
const connected = sockets.length > 0;
return new Response(JSON.stringify({ connected, connectedCount: sockets.length }), {
headers: { "Content-Type": "application/json" },
});
}
private async handleProxyPost(request: Request): Promise<Response> {
const denied = this.requireAuth(request);
if (denied !== null) {
return denied;
}
const raw = await request.text();
const wsRequest = parseWsRequestJson(raw);
if (wsRequest === null) {
return jsonResponse(400, { error: "invalid proxy body" });
}
const sockets = this.ctx.getWebSockets();
const ws = sockets[0];
if (ws === undefined) {
return jsonResponse(503, { error: "no active websocket" });
}
return await new Promise<Response>((resolve) => {
const timer = setTimeout(() => {
this.pending.delete(wsRequest.id);
resolve(jsonResponse(504, { error: "gateway timeout" }));
}, PROXY_TIMEOUT_MS);
this.pending.set(wsRequest.id, {
resolve: (r: Response) => {
clearTimeout(timer);
this.pending.delete(wsRequest.id);
resolve(r);
},
timer,
});
try {
ws.send(JSON.stringify(wsRequest));
} catch {
clearTimeout(timer);
this.pending.delete(wsRequest.id);
resolve(jsonResponse(502, { error: "websocket send failed" }));
}
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === AGENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
return this.handleStatusGet(request);
}
if (url.pathname === AGENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
return this.handleProxyPost(request);
}
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("expected WebSocket upgrade", { status: 426 });
}
for (const ws of this.ctx.getWebSockets()) {
ws.close(1000, "replaced by new connection");
}
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
const wr = parseWsResponseJson(text);
if (wr === null) {
return;
}
const entry = this.pending.get(wr.id);
if (entry === undefined) {
return;
}
clearTimeout(entry.timer);
this.pending.delete(wr.id);
entry.resolve(wsResponseToHttp(wr));
}
async webSocketClose(
_ws: WebSocket,
_code: number,
_reason: string,
_wasClean: boolean,
): Promise<void> {
this.rejectAllPending("agent websocket closed");
}
async webSocketError(_ws: WebSocket, _error: unknown): Promise<void> {
this.rejectAllPending("agent websocket error");
}
private rejectAllPending(message: string): void {
const entries = [...this.pending.values()];
this.pending.clear();
for (const entry of entries) {
clearTimeout(entry.timer);
entry.resolve(jsonResponse(502, { error: message }));
}
}
}
+203 -16
View File
@@ -1,11 +1,21 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import {
AGENT_SOCKET_INTERNAL_PROXY_PATH,
AGENT_SOCKET_INTERNAL_STATUS_PATH,
AgentSocket,
} from "./agent-socket.js";
import type { WsRequest } from "./ws-protocol.js";
export { AgentSocket };
type Env = {
Bindings: {
ENDPOINTS: KVNamespace;
GATEWAY_SECRET: string;
DASHBOARD_API_KEY: string;
AGENT_SOCKET: DurableObjectNamespace<AgentSocket>;
};
};
@@ -33,9 +43,165 @@ function checkDashboardAuth(c: {
return key === c.env.DASHBOARD_API_KEY;
}
function isLocalAgentUrl(url: string): boolean {
try {
const u = new URL(url);
return u.hostname === "localhost" || u.hostname === "127.0.0.1";
} catch {
return false;
}
}
function buildForwardHeaders(raw: Headers, agentToken: string): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of raw) {
const lower = key.toLowerCase();
if (lower === "host" || lower === "authorization") {
continue;
}
if (
lower === "connection" ||
lower === "keep-alive" ||
lower === "proxy-connection" ||
lower === "transfer-encoding" ||
lower === "upgrade"
) {
continue;
}
out[key] = value;
}
if (agentToken !== "") {
out["X-Agent-Token"] = agentToken;
}
return out;
}
function buildDashboardProxyHeaders(raw: Headers, token: string): Headers {
const headers = new Headers(raw);
headers.delete("host");
headers.delete("Authorization");
if (token !== "") {
headers.set("X-Agent-Token", token);
}
return headers;
}
async function readBodyForWsProxy(method: string, req: Request): Promise<string | null> {
if (method === "GET" || method === "HEAD") {
return null;
}
const buf = await req.arrayBuffer();
return buf.byteLength === 0 ? null : new TextDecoder().decode(buf);
}
async function fetchThroughAgentSocket(
bindings: Env["Bindings"],
agent: string,
gateSecret: string,
wsRequest: WsRequest,
): Promise<Response> {
const stub = bindings.AGENT_SOCKET.get(bindings.AGENT_SOCKET.idFromName(agent));
return stub.fetch(
new Request(`https://do.internal${AGENT_SOCKET_INTERNAL_PROXY_PATH}`, {
method: "POST",
headers: {
Authorization: `Bearer ${gateSecret}`,
"Content-Type": "application/json",
},
body: JSON.stringify(wsRequest),
}),
);
}
async function fetchAgentWithRecordHeaders(
targetUrl: string,
method: string,
forwardRecord: Record<string, string>,
bodyStr: string | null,
): Promise<Response> {
const headers = new Headers();
for (const [k, v] of Object.entries(forwardRecord)) {
headers.set(k, v);
}
return fetch(targetUrl, {
method,
headers,
body: method !== "GET" && method !== "HEAD" ? (bodyStr ?? undefined) : undefined,
});
}
async function fetchAgentWithDashboardHeaders(
targetUrl: string,
method: string,
headers: Headers,
rawBody: BodyInit | null | undefined,
): Promise<Response> {
return fetch(targetUrl, {
method,
headers,
body: method !== "GET" && method !== "HEAD" ? rawBody : undefined,
});
}
async function fetchAgentSocketStatus(
env: Env["Bindings"],
name: string,
): Promise<{ ok: true; connected: boolean } | { ok: false }> {
try {
const id = env.AGENT_SOCKET.idFromName(name);
const stub = env.AGENT_SOCKET.get(id);
const resp = await stub.fetch(
new Request(`https://do${AGENT_SOCKET_INTERNAL_STATUS_PATH}`, {
method: "GET",
headers: { Authorization: `Bearer ${env.GATEWAY_SECRET}` },
}),
);
if (!resp.ok) {
return { ok: false };
}
const body = (await resp.json()) as { connected: boolean };
return { ok: true, connected: body.connected };
} catch {
return { ok: false };
}
}
function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean | null): string {
if (doConnected === true) {
return "online";
}
if (doConnected === false) {
if (isLocalAgentUrl(record.url)) {
return "offline";
}
const age = Date.now() - record.lastHeartbeat;
return age < TTL_SECONDS * 1000 ? "online" : "offline";
}
const age = Date.now() - record.lastHeartbeat;
return age < TTL_SECONDS * 1000 ? "online" : "offline";
}
// ── Health ──────────────────────────────────────────────────────────
app.get("/healthz", (c) => c.json({ ok: true }));
// ── Agent reverse WebSocket (GATEWAY_SECRET query param) ────────────
app.get("/ws/connect", async (c) => {
const secret = c.req.query("secret");
const name = c.req.query("name");
if (name === undefined || name === "") {
return c.json({ error: "name required" }, 400);
}
if (secret !== c.env.GATEWAY_SECRET) {
return c.json({ error: "unauthorized" }, 401);
}
if (c.req.header("Upgrade") !== "websocket") {
return c.text("expected WebSocket upgrade", 426);
}
const id = c.env.AGENT_SOCKET.idFromName(name);
const stub = c.env.AGENT_SOCKET.get(id);
return stub.fetch(c.req.raw);
});
// ── Gateway management (GATEWAY_SECRET auth) ────────────────────────
const gateway = new Hono<Env>();
@@ -95,11 +261,12 @@ gateway.get("/endpoints", async (c) => {
for (const key of list.keys) {
const record = await c.env.ENDPOINTS.get<EndpointRecord>(key.name, "json");
if (record) {
const age = Date.now() - record.lastHeartbeat;
const doStatus = await fetchAgentSocketStatus(c.env, record.name);
const doConnected = doStatus.ok ? doStatus.connected : null;
endpoints.push({
name: record.name,
url: record.url,
status: age < TTL_SECONDS * 1000 ? "online" : "offline",
status: endpointStatusFromKvAndDo(record, doConnected),
lastHeartbeat: record.lastHeartbeat,
});
}
@@ -110,7 +277,7 @@ gateway.get("/endpoints", async (c) => {
app.route("/api/gateway", gateway);
// ── API proxy: /api/agents/:agent/* → agent's tunnel URL (dashboard auth) ──
// ── API proxy: /api/agents/:agent/* → WebSocket (preferred) or agent tunnel URL (dashboard auth) ──
app.all("/api/agents/:agent/*", async (c) => {
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
const agent = c.req.param("agent");
@@ -120,26 +287,45 @@ app.all("/api/agents/:agent/*", async (c) => {
return c.json({ error: "agent not found" }, 404);
}
// Build target URL: strip /api/:agent prefix, forward the rest
const url = new URL(c.req.url);
const pathAfterAgent = url.pathname.replace(`/api/agents/${agent}`, "");
const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`;
const proxyPath = `/api${pathAfterAgent}${url.search}`;
const method = c.req.method;
const token = record.agentToken ?? "";
const forwardRecord = buildForwardHeaders(c.req.raw.headers, token);
const headers = new Headers(c.req.raw.headers);
headers.delete("host");
headers.delete("Authorization"); // don't forward dashboard key to agent
if (record.agentToken) {
headers.set("X-Agent-Token", record.agentToken);
const doStatus = await fetchAgentSocketStatus(c.env, agent);
if (doStatus.ok && doStatus.connected) {
const bodyStr = await readBodyForWsProxy(method, c.req.raw);
const wsRequest: WsRequest = {
id: crypto.randomUUID(),
method,
path: proxyPath,
headers: forwardRecord,
body: bodyStr,
};
const proxyResp = await fetchThroughAgentSocket(c.env, agent, c.env.GATEWAY_SECRET, wsRequest);
if (proxyResp.status !== 503) {
return new Response(proxyResp.body, {
status: proxyResp.status,
headers: proxyResp.headers,
});
}
try {
const resp = await fetchAgentWithRecordHeaders(targetUrl, method, forwardRecord, bodyStr);
return new Response(resp.body, {
status: resp.status,
headers: resp.headers,
});
} catch (err) {
return c.json({ error: "agent unreachable", detail: String(err) }, 502);
}
}
const headers = buildDashboardProxyHeaders(c.req.raw.headers, token);
try {
const resp = await fetch(targetUrl, {
method: c.req.method,
headers,
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
});
// Stream response back
const resp = await fetchAgentWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
return new Response(resp.body, {
status: resp.status,
headers: resp.headers,
@@ -149,4 +335,5 @@ app.all("/api/agents/:agent/*", async (c) => {
}
});
// biome-ignore lint/style/noDefaultExport: Cloudflare Workers entry expects default export
export default app;
@@ -0,0 +1,93 @@
/** Wire format for HTTP-over-WebSocket proxy between gateway Durable Object and local serve. */
export type WsRequest = {
id: string;
method: string;
path: string;
headers: Record<string, string>;
body: string | null;
};
export type WsResponse = {
id: string;
status: number;
headers: Record<string, string>;
body: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
/** Parse and validate a JSON payload as {@link WsRequest}. */
export function parseWsRequestJson(raw: string): WsRequest | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
if (!isRecord(parsed)) {
return null;
}
const id = parsed.id;
const method = parsed.method;
const path = parsed.path;
const headers = parsed.headers;
const body = parsed.body;
if (!isNonEmptyString(id) || !isNonEmptyString(method) || !isNonEmptyString(path)) {
return null;
}
if (!isRecord(headers)) {
return null;
}
const headerRecord: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v !== "string") {
return null;
}
headerRecord[k] = v;
}
if (body !== null && typeof body !== "string") {
return null;
}
return { id, method, path, headers: headerRecord, body: body === null ? null : body };
}
/** Parse and validate a JSON payload as {@link WsResponse}. */
export function parseWsResponseJson(raw: string): WsResponse | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
if (!isRecord(parsed)) {
return null;
}
const id = parsed.id;
const status = parsed.status;
const headers = parsed.headers;
const respBody = parsed.body;
if (!isNonEmptyString(id) || typeof status !== "number" || !Number.isFinite(status)) {
return null;
}
if (!isRecord(headers)) {
return null;
}
const headerRecord: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v !== "string") {
return null;
}
headerRecord[k] = v;
}
if (typeof respBody !== "string") {
return null;
}
return { id, status: Math.trunc(status), headers: headerRecord, body: respBody };
}
+7
View File
@@ -6,4 +6,11 @@ compatibility_date = "2025-04-01"
binding = "ENDPOINTS"
id = "88b118d1cfab4c049f9c1684848811a3"
[durable_objects]
bindings = [{ name = "AGENT_SOCKET", class_name = "AgentSocket" }]
[[migrations]]
tag = "add-agent-socket"
new_sqlite_classes = ["AgentSocket"]
# GATEWAY_SECRET is set via `wrangler secret put`
@@ -21,11 +21,13 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta>
return {
threadId: "test-thread",
depth: 0,
bundleHash: "TESTHASH00001",
start: {
role: START,
content: "test",
meta: {},
timestamp: Date.now(),
parentState: null,
} as StartStep,
steps,
};
+5 -1
View File
@@ -1,11 +1,15 @@
{
"name": "@uncaged/workflow-protocol",
"version": "0.3.1",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
},
"./moderator-table.js": {
"types": "./dist/moderator-table.d.ts",
"import": "./src/moderator-table.ts"
}
},
"peerDependencies": {
@@ -4,6 +4,8 @@ export type StartNodePayload = {
name: string;
hash: string;
depth: number;
/** Parent thread's head state hash at spawn time. `null` for top-level workflows. */
parentState: string | null;
};
export type StartNode = {
@@ -20,6 +22,8 @@ export type StateNodePayload = {
ancestors: string[];
compact: string | null;
timestamp: number;
/** Child thread's final state hash (workflow-as-agent). `null` when no child spawned. */
childThread: string | null;
};
export type StateNode = {
+7 -5
View File
@@ -9,16 +9,18 @@ export type {
} from "./cas-types.js";
export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
@@ -27,6 +29,8 @@ export type {
ResolvedModel,
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleStep,
@@ -37,6 +41,8 @@ export type {
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
@@ -50,7 +56,3 @@ export { END, START } from "./types.js";
// ── Constructor functions ──────────────────────────────────────────
export { err, ok } from "./result.js";
// ── Moderator Table ────────────────────────────────────────────────
export { tableToModerator } from "./moderator-table.js";
+36 -2
View File
@@ -27,9 +27,22 @@ export type WorkflowRoleDescriptor = {
schema: WorkflowRoleSchema;
};
/** Serializable routing edges derived from a moderator transition table. */
export type WorkflowGraphEdge = {
from: string;
to: string;
condition: string;
conditionDescription: string | null;
};
export type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph;
};
// ── Role & Thread ──────────────────────────────────────────────────
@@ -41,6 +54,7 @@ export type RoleOutput = {
contentHash: string;
meta: Record<string, unknown>;
refs: string[];
childThread: string | null;
};
export type StartStep = {
@@ -48,6 +62,7 @@ export type StartStep = {
content: string;
meta: Record<string, never>;
timestamp: number;
parentState: string | null;
};
export type RoleStep<M extends RoleMeta> = {
@@ -63,6 +78,7 @@ export type RoleStep<M extends RoleMeta> = {
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
depth: number;
bundleHash: string;
start: StartStep;
steps: RoleStep<M>[];
};
@@ -127,13 +143,31 @@ export type ExtractFn = <T extends Record<string, unknown>>(
contentHash: string,
) => Promise<ExtractResult<T>>;
export type AgentFn = (ctx: AgentContext) => Promise<string>;
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
export type AgentFnResult = string | { output: string; childThread: string | null };
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
export type AgentFn = (ctx: AgentContext) => Promise<AgentFnResult>;
/** @deprecated Use {@link AdapterBinding} instead. Will be removed in a future release. */
export type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// ── Adapter (replaces Agent) ────────────────────────────────────────
export type RoleResult<T> = { meta: T; childThread: string | null };
export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
export type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
// ── Workflow Runtime & Definition ──────────────────────────────────
export type WorkflowRuntime = {
@@ -160,7 +194,7 @@ export type Moderator<M extends RoleMeta> = (
export type WorkflowDefinition<M extends RoleMeta> = {
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>;
table: ModeratorTable<M>;
};
// ── Declarative Moderator Table ────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-reactor",
"version": "0.3.1",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-register",
"version": "0.3.1",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
@@ -1,12 +1,35 @@
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-protocol";
import type {
ModeratorTable,
ModeratorTransition,
RoleMeta,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowGraph,
WorkflowGraphEdge,
} from "@uncaged/workflow-protocol";
import { END } from "@uncaged/workflow-protocol";
import * as z from "zod/v4";
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
import type { WorkflowRoleSchema } from "./types.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
const { $schema: _drop, ...rest } = json;
return rest as WorkflowRoleSchema;
}
function graphFromTable<M extends RoleMeta>(table: ModeratorTable<M>): WorkflowGraph {
const edges: WorkflowGraphEdge[] = [];
const entries = Object.entries(table) as Array<[string, ModeratorTransition<M>[]]>;
for (const [from, transitions] of entries) {
for (const t of transitions) {
const conditionName = t.condition === "FALLBACK" ? "FALLBACK" : t.condition.name;
const conditionDescription = t.condition === "FALLBACK" ? null : t.condition.description;
const to = t.role === END ? END : t.role;
edges.push({ from, to, condition: conditionName, conditionDescription });
}
}
return { edges };
}
export function buildDescriptor<M extends RoleMeta>(
def: WorkflowDefinition<M>,
): WorkflowDescriptor {
@@ -20,5 +43,9 @@ export function buildDescriptor<M extends RoleMeta>(
schema: stripJsonSchemaMeta(rawJsonSchema),
};
}
return { description: def.description, roles };
return {
description: def.description,
roles,
graph: graphFromTable(def.table),
};
}
@@ -37,13 +37,7 @@ function isAllowedImportSpecifier(spec: string): boolean {
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
return false;
}
if (
spec === "@uncaged/workflow" ||
spec === "@uncaged/workflow-runtime" ||
spec === "@uncaged/workflow-protocol" ||
spec === "@uncaged/workflow-cas" ||
spec === "@uncaged/workflow-util"
) {
if (spec.startsWith("@uncaged/workflow")) {
return true;
}
return isBuiltin(spec);
@@ -114,7 +108,8 @@ function bindingInitializerIsCallable(init: Node): boolean {
return (
init.type === "FunctionExpression" ||
init.type === "ArrowFunctionExpression" ||
init.type === "CallExpression"
init.type === "CallExpression" ||
init.type === "Identifier"
);
}
@@ -404,7 +399,7 @@ export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Re
if (!descriptorExportExists(program)) {
return err(
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles }")',
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles, graph }")',
);
}
@@ -9,6 +9,8 @@ export type {
ExtractedBundleExports,
WorkflowBundleValidationInput,
WorkflowDescriptor,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./types.js";

Some files were not shown because too many files have changed in this diff Show More