Compare commits

...

105 Commits

Author SHA1 Message Date
xiaoju d1a0a135d4 feat: Phase 7 — CLI json-cas commands
- json-cas init/workflow register+show/thread show/node get+list+walk
- Nested subcommand dispatch under 'json-cas' group
- Default store: ~/.uncaged/workflow/json-cas/
- 15 tests passing, biome clean

Closes #304
小橘 <xiaoju@shazhou.work>
2026-05-18 03:04:36 +00:00
xiaoju 34e00bebdf chore: switch json-cas deps from file: to npm ^0.1.0
小橘 <xiaoju@shazhou.work>
2026-05-18 02:49:49 +00:00
xiaoju 33cf23ed01 Merge pull request 'feat: Phase 5+6 — React layer + Agent model' (#303) from feat/294-phase5-react-layer into main 2026-05-18 02:47:31 +00:00
xiaoju 94c719870f feat: Phase 5 — React layer instrumentation
- json-cas-react-recorder.ts: writeReactSession stores full ReAct trace
- ReactTrace/ReactTurnTrace/ReactToolCallTrace types
- JsonCasAgentResult with optional react trace
- Engine integration: real react-session when trace provided, placeholder when null
- 10 new tests (29 total for json-cas engine), biome clean

Closes #301
小橘 <xiaoju@shazhou.work>
2026-05-18 02:47:18 +00:00
xiaoju 5af2d54e0f Merge pull request 'feat: Phase 4 — json-cas engine migration' (#300) from feat/294-phase4-engine-migration into main 2026-05-18 02:38:04 +00:00
xiaoju e01c08dacb feat: Phase 4 — json-cas engine (new engine alongside old)
- json-cas-engine.ts: new engine using json-cas Store + typed nodes
- json-cas-context.ts: build ThreadContext from thread-step chain
- json-cas-types.ts: engine types (JsonCasEngineIo, JsonCasAgentFn, etc.)
- thread-start/step/end/content nodes in json-cas format
- JSONata moderator via evaluateModerator
- react placeholder (Phase 5 will fill in)
- 21 tests passing, biome clean

Closes #299
小橘 <xiaoju@shazhou.work>
2026-05-18 02:37:05 +00:00
xiaoju f9d3d38008 Merge pull request 'feat: Phase 3 — workflow JSON definitions in CAS' (#298) from feat/294-phase3-workflow-json into main 2026-05-18 02:27:55 +00:00
xiaoju 9e99e58405 feat: Phase 3 — workflow JSON definitions in CAS
- New package: @uncaged/workflow-json-def
- registerWorkflow/loadWorkflow for CAS round-trip
- solve-issue and develop templates converted to pure JSON
- Zod schemas → JSON Schema, moderator → JSONata
- 30 tests passing, biome clean

Closes #297
小橘 <xiaoju@shazhou.work>
2026-05-18 02:24:52 +00:00
xiaoju 6af3059fb4 Merge pull request 'feat: JSONata moderator engine (Phase 2 of #294)' (#296) from feat/294-jsonata-moderator into main 2026-05-18 02:13:56 +00:00
xiaoju dfeba9d8fc feat: JSONata moderator engine (Phase 2 of #294)
- evaluateModerator(rules, context) with JSONata expression evaluation
- Fallback (when: null), conditional branching, no-match → __end__
- Full develop workflow moderator ported to JSONata
- 31 tests passing

Closes #295
小橘 <xiaoju@shazhou.work>
2026-05-18 02:08:03 +00:00
xingyue 0da1aabfab Merge pull request 'docs: add .cards/ architecture documentation' (#293) from docs/architecture-cards into main 2026-05-17 07:40:09 +00:00
xiaoju bb3618cc42 chore: remove _spec.md from cards (build artifact)
小橘 <xiaoju@shazhou.work>
2026-05-17 07:39:04 +00:00
xiaoju 2b21d981dd docs: add .cards/ architecture documentation
12 interlinked architecture cards + index, covering:
- Core: Bundle, Thread, CAS, Registry
- Execution: Engine, Role, Agent Binding, Reactor
- Tooling: CLI, Dashboard, Package Map
- Authoring: Workflow Templates

小橘 <xiaoju@shazhou.work>
2026-05-17 07:34:02 +00:00
xiaomo ebfb99bf4c Merge pull request 'refactor: rename casRef to x-cas-ref for JSON Schema compliance (Phase 3)' (#292) from feat/285-phase3-x-cas-ref into main 2026-05-16 11:55:24 +00:00
xiaoju 33f9425848 refactor: rename casRef to x-cas-ref for JSON Schema compliance
Rename .meta({ casRef: true }) to .meta({ 'x-cas-ref': true }) across
all schema annotations. Zod v4 toJSONSchema automatically outputs
x-cas-ref in the generated JSON Schema, so buildDescriptor preserves
the annotation without any code changes.

Add buildDescriptor test verifying x-cas-ref appears in descriptor
output for annotated fields and is absent for plain fields.

220 tests pass, 0 fail.

Fixes #291, Refs #285
2026-05-16 11:52:17 +00:00
xiaomo 2b707fb44e Merge pull request 'refactor: replace extractRefs with schema casRef annotations (Phase 2)' (#290) from feat/285-phase2-remove-extractrefs into main 2026-05-16 11:50:47 +00:00
xiaoju 6306b23a9f refactor: replace extractRefs with schema casRef annotations
Migrate all templates to use .meta({ casRef: true }) on Zod schema
fields instead of manual extractRefs functions. Remove extractRefs
from RoleDefinition type entirely.

- develop: planner phases[].hash, coder completedPhase annotated
- solve-issue, smoke, init templates: extractRefs removed
- create-workflow.ts: uses collectCasRefs(schema, meta)
- RoleDefinition: extractRefs field removed (breaking)

218 tests pass, 0 fail.

Fixes #289, Refs #285
2026-05-16 10:48:45 +00:00
xiaomo 6bb8cf8315 Merge pull request 'feat: add collectCasRefs — schema-level CAS ref annotation (Phase 1)' (#288) from feat/285-cas-ref-annotation into main 2026-05-16 10:43:16 +00:00
xiaoju 93b7947d7c feat: add collectCasRefs — extract CAS refs from schema meta annotations
Replaces manual extractRefs functions with declarative schema-level
casRef annotations. Walks Zod v4 schemas recursively, collecting
string values from fields marked with .meta({ casRef: true }).

Supports: objects, arrays, discriminatedUnion, nullable/optional.

8/8 test cases pass (flat, nested, union, null, mixed).

Refs #285, addresses #286
2026-05-16 10:42:24 +00:00
xingyue 9584a86fb7 Merge pull request 'chore: fix biome cognitive complexity warnings' (#287) from chore/fix-biome-complexity-warnings into main 2026-05-16 10:40:27 +00:00
Scott Wei defc0afc27 chore: fix biome cognitive complexity warnings
Refactor dashboard graph/schema helpers and descriptor role validation
into smaller functions so bun run check passes without warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:33:00 +08:00
xiaomo 9f6633d5bf Merge pull request 'refactor(workflow-protocol): require AgentFn Opt generic' (#284) from refactor/agent-fn-required-opt into main 2026-05-16 10:27:07 +00:00
Scott Wei 7dadf874e1 refactor(workflow-protocol): require AgentFn Opt generic
Make AgentFn<Opt> always take a mandatory options argument, removing
the void conditional overload. Simplify createAgentAdapter, restore
exports needed by tests, and fix CLI test bundles to use cas.put
instead of disallowed @uncaged/* imports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:23:07 +08:00
xiaomo ba90214af6 Merge pull request 'chore: internalize unused exports across all packages' (#283) from chore/audit-exports-cleanup into main 2026-05-16 09:59:57 +00:00
xiaoju 5bbac3e4f7 chore: internalize unused exports across all packages
Audit public API surfaces using reachability analysis from application
entry points (Worker, CLI, Dashboard). Symbols not reachable from any
application's customization tree are removed from package index.ts files.

Source files and internal usage are untouched — only the public export
surface is narrowed.

Changes by package:
- workflow-util: -7 exports (base32 internals, logger config types)
- workflow-cas: -12 exports (merkle internals, serialization details)
- workflow-execute: -24 exports (engine internals, LLM extract details)
- workflow-reactor: -4 exports (reactor config/invocation internals)
- workflow-register: -8 exports (redundant protocol re-exports, internal YAML fns)
- workflow-runtime: curated re-export subset (stop full protocol re-export)
- workflow-util-agent: -5 exports (internal agent helpers)
- workflow-agent-cursor: -1 export (validateCursorAgentConfig)
- workflow-agent-hermes: -1 export (validateHermesAgentConfig)

Note: workflow-protocol index.ts unchanged — downstream packages still
import removed symbols via internal paths. Protocol cleanup requires
updating workflow-runtime/src/types.ts first (separate PR).

Refs #273, #274, #275, #276, #277, #278, #279, #280, #281, #282
2026-05-16 09:58:56 +00:00
xiaomo 131021b1a7 Merge pull request 'chore: remove symlink dead code' (#271) from chore/remove-symlink-dead-code into main 2026-05-16 06:22:34 +00:00
xiaoju e42555fd9c chore: remove symlink dead code
Now that bundles are fully self-contained (no external @uncaged/* imports),
the symlink mechanism is no longer needed.

- Delete ensure-uncaged-workflow-symlink.ts
- Remove ensureUncagedWorkflowSymlink from all imports/exports
- Remove ExtractBundleExportsOptions type (storageRoot param)
- Simplify extractBundleExports to single-arg signature
- Clean up stale comments
2026-05-16 06:21:34 +00:00
xiaomo 3a26eb28e5 Merge pull request 'chore: make bundle fully self-contained, no external imports' (#270) from chore/no-external-bundle into main 2026-05-16 06:16:28 +00:00
xiaoju c1a17b707c chore: make bundle fully self-contained, no external imports
- Remove uncagedWorkflowExternals() from scaffold build script
- Remove --external from Bun.build config
- Tighten bundle validator: only Node built-ins allowed, all deps must be inlined
- Update skill.ts documentation

Bundles are now deterministic — same Node/Bun version = same behavior,
no dependency resolution at runtime.
2026-05-16 05:12:49 +00:00
xiaoju 4ea1e0d8a4 chore: publish 0.5.0-alpha.4 — unified env() API 2026-05-15 10:19:38 +00:00
xiaoju b1a9d2ec3f refactor: replace requireEnv/optionalEnv with env(name, fallback)
Bundles must run without env vars — env vars are overrides, not requirements.
Single function: env(name, fallback) always returns string with a default.

- Removed requireEnv and optionalEnv
- Updated bundle entries, tests, and skill docs

小橘 🍊
2026-05-15 10:07:49 +00:00
xiaoju 2b8707a706 style: use optionalEnv fallback param instead of ?? operator
小橘 🍊
2026-05-15 09:52:07 +00:00
xiaoju 241bfbf6d9 fix: hardcode absolute paths for agent CLI defaults in bundle entry
Validator requires absolute paths — bare command names like 'cursor-agent'
fail validation. Use `which` to discover the path, write it directly.

小橘 🍊
2026-05-15 09:49:42 +00:00
xiaoju 40530d757e fix: use optionalEnv with defaults for agent CLI paths in bundle entry
requireEnv causes silent worker crash when env vars are missing —
thread shows 0 steps with no error. Use optionalEnv + sensible defaults.

Also added pitfall guidance in skill author docs.

小橘 🍊
2026-05-15 09:25:39 +00:00
xiaoju 0f3661b566 refactor: unify GATEWAY_SECRET + DASHBOARD_API_KEY into WORKFLOW_DASHBOARD_SECRET 2026-05-15 09:12:12 +00:00
xingyue 9c44c709e9 fix(dashboard): replace broken partial-order layering with longest-path
The previous computeLayers used a reachability-based relation (a « b)
with depth tiebreaker to define a < b, then grouped nodes by == into
equivalence classes. However == is NOT an equivalence relation (fails
transitivity), making the grouping order-dependent and incorrect for
graphs with parallel branches.

Replace with standard Sugiyama longest-path layering:
1. DFS to detect and remove back-edges (break cycles)
2. Kahn's topological sort on the resulting DAG
3. rank(n) = max(rank(pred) + 1) for longest-path assignment
4. Group nodes by rank into layers

Also removes the experimental dagre layout strategy that was added
for comparison — longest-path produces better results for our
workflow graphs.
2026-05-15 14:48:02 +08:00
xingyue 8892ab9978 fix(dashboard): add left/right handles to end node for skip-forward edges 2026-05-15 14:15:35 +08:00
xingyue 7ec86d82a3 fix(dashboard): sidePath supports both feedback and skip-forward edges 2026-05-15 14:11:58 +08:00
xingyue f728b36e8d fix(dashboard): route skip-forward edges to side (planner→end was hidden) 2026-05-15 14:10:25 +08:00
xingyue 3431d3070b refactor(dashboard): reachability-based partial order for graph layout
Replace linear spine walk with a proper partial order:
- a « b = a ~> b AND NOT b ~> a (strict precedence)
- a ~ b = incomparable under «
- depth tiebreaker for incomparable nodes
- Equivalent nodes (same layer) placed side-by-side horizontally
2026-05-15 14:05:53 +08:00
xingyue 576df067d4 chore: remove generated bundle from git, fix biome format 2026-05-15 09:42:33 +08:00
xingyue a46a225d04 fix(dashboard): render system prompt as markdown 2026-05-15 09:41:52 +08:00
xiaoju f74b482cc0 chore: version 0.5.0-alpha.3, add publish-all script
- scripts/publish-all.mjs: pins workspace:^ before npm publish, restores after
- Workaround for bun publish workspace:^ resolution bug in pre mode

小橘 🍊
2026-05-15 01:37:27 +00:00
xiaomo 89abfdc257 Merge pull request 'feat(dashboard): show system prompt per role' (#269) from feat/show-system-prompt into main 2026-05-15 01:28:12 +00:00
xiaoju 77e395b913 chore: version 0.5.0-alpha.1
小橘 🍊
2026-05-15 01:27:54 +00:00
xingyue b65a006d45 feat(dashboard): show system prompt per role in workflow detail
- Add systemPrompt to WorkflowRoleDescriptor (protocol)
- Propagate systemPrompt through buildDescriptor and validateWorkflowDescriptor
- Display system prompt as collapsible <details> in RoleCard
2026-05-15 09:27:03 +08:00
xiaomo 5994548f0b Merge pull request 'chore: add .env.example with all supported env vars' (#207) from chore/205-env-example into main 2026-05-15 01:25:10 +00:00
xiaoju 0871ae54ea fix: remove unused WORKFLOW_LLM_API_KEY per review
No consumers after #262 removed llmProvider from bundle entries.
WORKFLOW_CURSOR_WORKSPACE has no env var counterpart, not added.

小橘 🍊
2026-05-15 01:24:29 +00:00
xiaoju 9576d69032 chore: enter changeset pre mode (alpha), version 0.5.0-alpha.0
小橘 🍊
2026-05-15 01:22:44 +00:00
xiaoju 64dadf114d fix: align .env.example with actual env vars
- Remove non-existent: WORKFLOW_LLM_BASE_URL, WORKFLOW_LLM_MODEL, WORKFLOW_CURSOR_LLM_PROVIDER
- Add missing: WORKFLOW_CURSOR_COMMAND (required for develop workflow)

小橘 🍊
2026-05-15 01:20:38 +00:00
xiaomo baaa1d1dc8 Merge pull request 'chore: biome format fix + pre-push hook' (#268) from chore/biome-fix-and-pre-push-hook into main 2026-05-15 01:20:38 +00:00
xiaoju 3074cd5f0c chore: add .env.example with all supported env vars
Documents all environment variables used across the workflow engine:
- LLM config (base URL, API key, model)
- Cursor agent config (model, timeout, provider)
- Hermes agent config (model, timeout)
- Storage and display options

Fixes #205
2026-05-15 01:20:38 +00:00
xingyue 15edc99c72 chore: add pre-push hook (check + test) and fix lint-log-tags for macOS 2026-05-15 09:11:39 +08:00
xingyue 153178c545 fix: biome format issues (12 errors) 2026-05-15 09:10:39 +08:00
xiaomo fac215bd21 Merge pull request 'chore(dashboard): remove unused _parentRequired param' (#267) from chore/remove-parentRequired-param into main 2026-05-15 00:30:51 +00:00
xingyue 9822e68c55 chore(dashboard): remove unused _parentRequired param from flattenSchema 2026-05-15 08:25:39 +08:00
xiaomo 764b73209e Merge pull request 'feat(dashboard): workflow detail 独立子页面' (#264) from feat/workflow-detail-layout into main 2026-05-15 00:23:45 +00:00
xiaomo e7987c4cd7 Merge pull request 'fix(cli): race condition in thread rm + flaky test' (#266) from fix/265-flaky-thread-rm into main 2026-05-15 00:21:16 +00:00
xiaoju 942ff4b1a4 fix(cli): race condition in thread rm + flaky test (#265)
Two fixes:
1. cmdThreadRemove: always call both removeThreadEntry and
   removeThreadHistoryEntries regardless of resolved source,
   preventing race where thread moves from active to history
   between resolve and delete.
2. Test: add waitUntilRunningFileAbsent before thread show/rm,
   matching the pattern used by adjacent test cases.

Verified 5x consecutive runs with 0 failures.

Closes #265
2026-05-14 13:17:04 +00:00
xingyue f5977c46c6 feat(dashboard): workflow detail as separate page with fixed graph sidebar
- Workflow list is now a simple clickable list (no expand/collapse)
- Clicking a workflow navigates to dedicated detail page (#client/workflows/name)
- Detail page: fixed graph sidebar on left, scrollable role cards on right
- Back button returns to workflow list
- Route: added workflowName to hash routing
2026-05-14 21:05:00 +08:00
xiaomo 71ccf8d03c Merge pull request 'chore(util-agent): remove dead createTextAdapter / TextProducerFn' (#263) from chore/252-remove-text-adapter into main 2026-05-14 12:58:22 +00:00
xingyue 510b49287a Merge pull request 'feat(dashboard): redesign workflow detail layout' (#257) from feat/workflow-detail-layout into main 2026-05-14 12:58:04 +00:00
xiaoju bb6b309efa chore(util-agent): remove dead createTextAdapter / TextProducerFn
All adapters migrated to createAgentAdapter in #261. No consumers remain.

Refs #252
2026-05-14 12:23:01 +00:00
xiaomo 56db22a908 Merge pull request 'refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter' (#262) from feat/261-adapter-migration into main 2026-05-14 12:19:37 +00:00
xiaoju 2a1b7b0aeb refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter
- LLM: AgentFn<{prompt}> + createAgentAdapter, chatCompletionText unchanged
- Hermes: AgentFn<{prompt}> + createAgentAdapter, config validation in extract
- Cursor: AgentFn<{prompt, workspace}> + createAgentAdapter, workspace
  extraction moved to extract fn, AgentFn itself only receives resolved options

All public API signatures preserved. createTextAdapter/TextProducerFn retained.

Closes #261, Phase 2 of #252
2026-05-14 10:22:37 +00:00
xingyue d037eca4ae feat(dashboard): recursive schema rendering with nested object, array, oneOf
- Nested object: expand properties with └─ indentation
- object[]: show type as 'object[]', expand items.properties
- string[]/number[]: show type directly, no expansion
- oneOf/discriminatedUnion: variant headers with ├/└ connectors
  - Auto-detect discriminator field (const values)
  - Skip discriminator in variant field list
- Recursive to arbitrary depth

Phase 1+2 of #258
2026-05-14 18:19:01 +08:00
xingyue b9d543a465 fix: move hooks before early returns to fix Rules of Hooks crash 2026-05-14 16:53:47 +08:00
xiaomo 07f52594d1 Merge pull request 'feat(protocol): AgentFn<Opt> type + createAgentAdapter bridge' (#256) from feat/252-agent-fn into main 2026-05-14 08:53:30 +00:00
xingyue c7b426ff5a feat(dashboard): redesign workflow detail layout
Left sidebar: compact workflow graph with clickable nodes for navigation.
Right panel: workflow overview card + per-role cards with meta schema tables.

Clicking a node in the graph scrolls to the corresponding role card.
All nodes are lit in static view (workflow definition, not a thread).
2026-05-14 16:47:29 +08:00
xiaoju 4582274ba4 feat(protocol): AgentFn<Opt> type + createAgentAdapter bridge
Add AgentFn<Opt = void> as the formal agent boundary type:
- Input: ThreadContext (fixed), Output: string (fixed)
- Opt: agent-specific structured options (e.g. { workspace } for Cursor)

Add createAgentAdapter<Opt>(agent, extract) → AdapterFn bridge in
workflow-util-agent, plus createSimpleAgentAdapter for Opt = void.

Also fixes: workflow-cas composite flag + cursor tsconfig reference.

Refs #252
2026-05-14 08:41:22 +00:00
xiaomo d140801337 Merge pull request 'feat(dashboard): graph node click improvements' (#255) from feat/graph-interactions into main 2026-05-14 08:29:29 +00:00
xingyue 4563f1bb5e fix(dashboard): start node lights up when thread-start exists
Previously __start__ only lit when role records existed. Now it lights
up as soon as a thread-start record is present (i.e. the trigger prompt).
2026-05-14 16:24:30 +08:00
xingyue 59b7e89028 feat(dashboard): graph node click improvements
- Reduce feedback edge offset (140→80) for tighter layout
- Terminal nodes (start/end) now clickable when lit
- Unlit nodes have no cursor-pointer and ignore clicks
- Role nodes cycle through occurrences on repeated clicks
- Start node click scrolls to thread prompt
- End node click scrolls to bottom
- All records get data-record-index for scroll targeting

Ref: #247
2026-05-14 16:20:00 +08:00
xingyue 019d8c1ee9 fix: explicit handle IDs — forward edges use top/bottom, feedback uses sides
Prevent React Flow from auto-routing forward edges to side handles.
All handles now have explicit IDs and all edges specify sourceHandle/targetHandle.
2026-05-14 15:59:55 +08:00
xingyue 5e783e7a24 fix(dashboard): feedback edges connect from node sides via left/right handles (#247)
What: Feedback (back) edges now connect from the left/right side of nodes
instead of top/bottom, making the routing visually clearer.

Changes:
- role-node.tsx: add Left/Right handles for feedback edge connections
- use-layout.ts: set sourceHandle/targetHandle for feedback edges
- condition-edge.tsx + use-layout.ts: increase FEEDBACK_OFFSET_X to 140

Ref: #247
2026-05-14 15:52:24 +08:00
xingyue a450a88b16 fix(dashboard): increase feedback edge offset for clarity (#247) 2026-05-14 14:38:05 +08:00
xingyue 5b47317cef fix(dashboard): fix crash — t.state → data.state in role-node (#247) 2026-05-14 14:34:57 +08:00
xiaomo 3384c38d02 Merge pull request 'fix(dashboard): restore graph visual preferences (#247)' (#250) from fix/dashboard-graph-visual-247 into main 2026-05-14 03:43:32 +00:00
xingyue b370d96504 fix(dashboard): alternate feedback edges left/right (#247 Phase 2)
What: Feedback (back) edges now alternate between left and right sides
instead of all routing to the right.

Why: Multiple feedback edges targeting the same node (e.g. reviewer→coder
and tester→coder) were overlapping on the right side.

Changes:
- types.ts: add feedbackSide field to ConditionEdgeData
- use-layout.ts: track feedback count per target, alternate sides
- condition-edge.tsx: feedbackPath() accepts side param, mirrors path for left

Ref: #247, closes #249
2026-05-14 11:42:06 +08:00
xingyue 8cae114c7e fix(dashboard): unified solid edges, hide FALLBACK labels, conditional cursor (#247 Phase 1)
What: Restore graph visual preferences — all edges solid, FALLBACK labels hidden,
inactive nodes not clickable.

Why: Visual consistency and cleaner graph appearance per design preferences.

Changes:
- condition-edge.tsx: remove strokeDasharray, unify stroke color, hide FALLBACK labels
- role-node.tsx: cursor-pointer only on non-default state nodes

Ref: #247, closes #248
2026-05-14 11:39:51 +08:00
xiaomo c2c6fc5304 Merge pull request 'refactor: cursor-agent uses runtime.extract for workspace detection' (#246) from fix/cursor-agent-runtime-extract into main 2026-05-13 15:57:36 +00:00
xiaoju 94f725c50b refactor: cursor-agent uses runtime.extract for workspace detection
- Remove llmProvider and workspace from CursorAgentConfig (now just command/model/timeout)
- extractWorkspacePath uses runtime.extract + runtime.cas instead of standalone reactor
- TextProducerFn signature gains runtime parameter: (ctx, prompt, runtime)
- develop-entry.ts hardcodes cursor-agent path, no more env var dependency
- Drop @uncaged/workflow-reactor dep from workflow-agent-cursor
- Update tests for simplified config

小橘 <xiaoju@shazhou.work>
2026-05-13 15:51:43 +00:00
xiaomo 9b23e6f85a Merge pull request 'refactor(serve): remove tunnel + eliminate HTTP round-trip in gateway mode' (#245) from refactor/serve-remove-http-tunnel into main 2026-05-13 15:29:05 +00:00
xingyue 238a94f7a6 fix: restore original migration, rename pathAfterAgent → pathAfterClient
- wrangler.toml: keep first migration as AgentSocket (already applied),
  second migration handles the rename
- index.ts: pathAfterAgent → pathAfterClient
2026-05-13 23:28:20 +08:00
xingyue 236c771e4e refactor: rename serve→connect, agent→client across CLI/gateway/dashboard
- CLI: 'serve' command → 'connect', remove local-only HTTP mode
  (no WORKFLOW_GATEWAY_SECRET now errors instead of falling back)
- CLI: agentToken → clientToken, X-Agent-Token → X-Client-Token
- Gateway: AgentSocket DO → ClientSocket, AGENT_SOCKET → CLIENT_SOCKET
- Gateway: /api/agents/:agent/* → /api/clients/:client/*
- Gateway: agentToken → clientToken in EndpointRecord and register API
- Dashboard: all agent references → client throughout UI and API layer
- Added Durable Object migration for the class rename
2026-05-13 23:28:20 +08:00
xingyue 0ffd84cf7d refactor(serve): WS client calls app.fetch directly, no HTTP server in gateway mode
- WS client receives app.fetch function instead of localPort
- Gateway mode no longer starts a local HTTP server
- Local-only mode (no secret) still starts HTTP server as before
- Removes unnecessary HTTP round-trip for gateway requests
2026-05-13 23:28:20 +08:00
xiaomo e14643a50b Merge pull request 'chore: add output rules to develop roles — suppress verbose diffs' (#244) from chore/slim-role-output into main 2026-05-13 15:01:02 +00:00
xiaoju 76830c5e22 chore: add log-tag lint + fix biome errors + pre-push hook
- scripts/lint-log-tags.sh: static check for invalid Crockford Base32 log tags (I/L/O/U)
- fix two invalid log tags in ws-client.ts (6CJX2RLP→6CJX2R8P, T9W2KL5H→T9W2K35H)
- fix biome errors: unused import, exhaustive deps, cognitive complexity suppression
- add pre-push git hook running bun run check
- integrate lint-log-tags into bun run check pipeline

Refs #244
2026-05-13 14:59:20 +00:00
xingyue 90a388f5ab refactor(serve): remove tunnel/cloudflared, simplify to WS-only gateway
- Delete tunnel.ts (startTunnel/cloudflared), rename to gateway.ts
- Remove --no-tunnel, --tunnel-url flags
- ServeOptions: drop noTunnel, tunnelUrl fields
- Two modes: gateway (with WORKFLOW_GATEWAY_SECRET) or local-only
- WS reverse connection is the only gateway transport
2026-05-13 22:46:48 +08:00
xiaoju 82e40f0c21 feat: planner abort path — fail fast when workspace info is missing
- PlannerMeta is now a discriminated union: planned | aborted
- Moderator routes aborted planner → END (no coder invocation)
- System prompt requires absolute workspace path, instructs abort if missing
- extractRefs handles both variants
- Test: 'planner aborted → END'

Signed-off-by: 小橘 <xiaoju@shazhou.work>
2026-05-13 14:20:23 +00:00
xiaoju 8d650326db chore: add output rules to all develop roles — suppress verbose diffs
Planner, coder, reviewer, and tester system prompts now explicitly
instruct the agent to keep responses short and avoid pasting diffs,
code blocks, or full build logs. This reduces CAS storage and token
waste when downstream roles read the thread.

Signed-off-by: 小橘 <xiaoju@shazhou.work>
2026-05-13 13:52:04 +00:00
xingyue dd3eec7d35 docs: update CLAUDE.md — changesets + npmjs registry 2026-05-13 21:22:15 +08:00
xingyue 9276689cb6 chore: switch to npmjs registry, publish v0.4.5
- Remove Gitea npm registry, use npmjs.org
- changeset publish works natively with npmjs
- release script: build + test + changeset publish
- Remove custom release.sh, all via changesets
2026-05-13 21:20:18 +08:00
xingyue b4584cbaa6 chore: publish v0.4.3 — include src/ in published packages
bun runtime resolves the 'bun' exports condition to ./src/index.ts,
but src/ was not in the files array so consumers got ENOENT.
2026-05-13 21:11:17 +08:00
xingyue 1cf963a1fb chore: publish v0.4.2 — fix workspace deps, remove publish.sh
- workspace:* → workspace:^ (resolves to ^x.y.z instead of exact)
- Remove publish.sh, use changesets workflow
- changeset config: access public (Gitea compat)
- release script: build + test + changeset publish
2026-05-13 21:07:29 +08:00
xingyue ce5bc50210 chore: publish v0.4.1
小橘 <xiaoju@shazhou.work>
2026-05-13 20:59:59 +08:00
xiaomo 439e203113 Merge pull request 'feat: adopt @changesets/cli for synchronized version management' (#243) from feat/changesets-version-management into main 2026-05-13 12:57:41 +00:00
xingyue 522afdd4bd feat: adopt changesets + fix exports, bump to 0.4.0
- Install @changesets/cli with fixed mode (all @uncaged/* packages sync version)
- Fix package exports: add bun condition, point import to dist/
- Bump all packages to 0.4.0 via changeset version
- Auto-generated CHANGELOG.md for each package
- Ignore workflow-dashboard (private)
- Add npm scripts: changeset, version, release
- publish.sh: support workspace:^ prefix matching

Closes #241, Closes #242
2026-05-13 20:56:21 +08:00
xingyue ca644dabaa chore: bump all packages to 0.4.0, fix exports for publish
- All @uncaged/* packages → 0.4.0
- Internal deps: workspace:* → workspace:^ (resolves to ^0.4.0 on publish)
- Fix exports: add 'bun' condition for local dev (src), 'import' for consumers (dist)
- Remove stale 'main: src/index.ts' from 6 packages
- Fix publish.sh topo sort to match workspace:^ prefix

星月 <xingyue@shazhou.work>
2026-05-13 20:46:00 +08:00
xiaomo 9d9c00df98 Merge pull request 'chore: remove link-all.sh' (#240) from chore/remove-link-all into main 2026-05-13 10:16:22 +00:00
xiaoju a1c5dc3e92 chore: remove link-all.sh
Local symlink workflow replaced by Gitea npm registry publish flow.

Signed-off-by: 小橘 <xiaoju@shazhou.work>
2026-05-13 09:56:07 +00:00
xiaoju c85980f604 Merge pull request 'chore: merge publish-all.sh into publish.sh' (#238) from chore/merge-publish-scripts into main 2026-05-13 09:52:43 +00:00
xingyue eff5fb332a chore: merge publish.sh and publish-all.sh (#237)
- Topological sort from publish-all.sh replaces hardcoded order
- bun publish directly (no manual workspace:* replacement/restoration)
- bun run build + bun test (not npm run)
- --dry-run support (skips git commit/push)
- Delete publish-all.sh
- Update package.json scripts

Closes #237

Signed-off-by: 星月 <xingyue@shazhou.work>
2026-05-13 17:51:49 +08:00
xingyue 658a4a24ef chore: merge publish-all.sh into publish.sh
- Use bun pm pack for workspace:* resolution (no manual replace/restore)
- Topological sort replaces hardcoded PUBLISH_ORDER
- Registry unified to uncaged org
- Delete scripts/publish-all.sh
- Add --dry-run flag support

Closes #237
2026-05-13 17:44:06 +08:00
xingyue aabfd90a87 Merge pull request 'fix: auto-discover publishable packages + pre-publish test gate' (#236) from fix/auto-discover-publish into main 2026-05-13 09:42:55 +00:00
209 changed files with 9989 additions and 1929 deletions
+35
View File
@@ -0,0 +1,35 @@
# Uncaged Workflow Architecture
Uncaged Workflow is a monorepo implementing a workflow engine that executes single-file ESM bundles. Each workflow is identified by an XXH64 hash (Crockford Base32); execution state is stored in a content-addressable store (CAS) as immutable Merkle nodes. Agents are pluggable — the same workflow definition runs with Cursor, Hermes, a raw LLM, or a ReAct loop.
## Core Concepts
| Card | Description |
|------|-------------|
| [Bundle](./bundle.md) | A single-file `.esm.js` module with an XXH64 hash identity, stored in `~/.uncaged/workflow/bundles/` |
| [Thread](./thread.md) | A single execution instance of a workflow, identified by a ULID, with CAS-linked state nodes |
| [CAS](./cas.md) | The content-addressable store that holds all immutable blobs — content, start nodes, and state nodes |
| [Registry](./registry.md) | `workflow.yaml` — maps workflow names to current and historical bundle hashes |
## Execution
| Card | Description |
|------|-------------|
| [Engine](./engine.md) | The three-phase loop that drives the workflow `AsyncGenerator` and writes each step to CAS |
| [Role](./role.md) | A named actor defined as pure data (`RoleDefinition`) — description, system prompt, and Zod schema |
| [Agent Binding](./agent-binding.md) | The runtime binding that connects a role to a concrete agent implementation via `AdapterFn` |
| [Reactor](./reactor.md) | The ReAct loop abstraction for LLM function-calling, used by both the extract phase and agent adapters |
## Tooling
| Card | Description |
|------|-------------|
| [CLI](./cli.md) | The `uncaged-workflow` command-line tool for managing workflows, threads, and CAS |
| [Dashboard](./dashboard.md) | A private React app for inspecting threads, workflows, and live execution via the gateway |
| [Package Map](./package-map.md) | All packages in the monorepo with their layer positions and dependency graph |
## Authoring
| Card | Description |
|------|-------------|
| [Workflow Templates](./workflow-templates.md) | The `solve-issue` and `develop` reference templates and how to author custom workflows |
+104
View File
@@ -0,0 +1,104 @@
# Agent Binding
> The runtime connection between a workflow's role definitions and a concrete agent implementation, expressed as an `AdapterBinding` passed to `createWorkflow`.
## Overview
Agent binding is how a workflow author specifies which agent executes each role. Roles are pure data (see [Role](./role.md)); the binding supplies the execution strategy. The same `WorkflowDefinition` can be run with different agents by changing the `AdapterBinding` — useful for testing, cost optimization, or environment-specific deployment.
An `AdapterFn` receives a role's `systemPrompt` and Zod `schema`, and returns a `RoleFn` — a function that takes `ThreadContext` and `WorkflowRuntime` and returns `RoleResult<T>`. The adapter is responsible for producing typed structured output directly; there is no separate extract phase when using adapters.
## Key Types
```typescript
// The core adapter interface
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
type RoleResult<T> = { meta: T; childThread: string | null };
// The binding passed to createWorkflow
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
```
`overrides` allows per-role adapters — for example, using Cursor for one role and an LLM for another within the same workflow.
## AgentFn (Legacy / Low-level)
Below the adapter layer, the original `AgentFn` type still exists for agent implementations that produce raw strings rather than structured output:
```typescript
type AgentFn<Opt = void> = Opt extends void
? (ctx: ThreadContext) => Promise<string>
: (ctx: ThreadContext, options: Opt) => Promise<string>;
```
The `createAgentAdapter` utility in `@uncaged/workflow-util-agent` wraps an `AgentFn` into an `AdapterFn` by composing it with extraction logic.
## Concrete Implementations
| Package | Export | Agent |
|---------|--------|-------|
| `@uncaged/workflow-agent-cursor` | `createCursorAgent` | Runs `cursor` CLI non-interactively in a workspace directory |
| `@uncaged/workflow-agent-hermes` | `createHermesAgent` | Runs `hermes chat` with `--yolo --quiet` (Nerve-style argv) |
| `@uncaged/workflow-agent-llm` | `createLlmAdapter` | Direct LLM completion via the OpenAI-compatible chat endpoint |
| `@uncaged/workflow-agent-react` | `createReactAdapter` | ReAct loop with file and shell tools (read, write, patch, exec) |
All four return an `AdapterFn` suitable for use in `AdapterBinding.adapter`.
## workflow-util-agent
`@uncaged/workflow-util-agent` provides two helpers shared by adapter implementations:
- **`buildThreadInput(ctx)`** — constructs the user-message string from thread context (task, previous steps, tool hints). Used by all CLI-based agents.
- **`spawnCli(command, args, opts)`** — spawns an external process (e.g., `cursor`, `hermes`) and captures stdout, with optional timeout.
- **`createAgentAdapter(agentFn, optionsFn)`** — wraps an `AgentFn<Opt>` into an `AdapterFn`, handling the options extraction step.
## Cursor Agent
`createCursorAgent(config)` invokes the `cursor` CLI binary:
```
cursor -p <fullPrompt> --model <model> --workspace <path> --output-format text --trust --force
```
The workspace path is taken from `config.workspace` or extracted from the thread context via `runtime.extract`.
## Hermes Agent
`createHermesAgent(config)` invokes `hermes chat`:
```
hermes chat -q <fullPrompt> --yolo --max-turns 90 --quiet [--model <model>]
```
## LLM Adapter
`createLlmAdapter(provider)` calls the OpenAI-compatible chat completions endpoint directly. It builds a two-message conversation (system + user) from the role's `systemPrompt` and `buildThreadInput` output, then extracts structured output from the response.
## React Adapter
`createReactAdapter(config)` creates a ReAct loop agent with four default tools: `read_file`, `write_file`, `patch_file`, and `shell_exec`. The loop continues until the agent calls the structured extraction tool or until `maxRounds` is exceeded.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | `AdapterFn`, `AdapterBinding`, `RoleFn`, `RoleResult`, `AgentFn` |
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — dispatches `adapterForRole` each iteration |
| `@uncaged/workflow-util-agent` | `src/build-agent-prompt.ts` | `buildThreadInput`, `buildAgentPrompt` |
| `@uncaged/workflow-util-agent` | `src/spawn-cli.ts` | `spawnCli` — subprocess runner with timeout |
| `@uncaged/workflow-util-agent` | `src/create-agent-adapter.ts` | `createAgentAdapter` — wraps `AgentFn` into `AdapterFn` |
| `@uncaged/workflow-agent-cursor` | `src/index.ts` | `createCursorAgent` |
| `@uncaged/workflow-agent-hermes` | `src/index.ts` | `createHermesAgent` |
| `@uncaged/workflow-agent-llm` | `src/create-llm-adapter.ts` | `createLlmAdapter` |
| `@uncaged/workflow-agent-react` | `src/create-react-adapter.ts` | `createReactAdapter` |
## See Also
- [Role](./role.md) — the pure data that the binding executes
- [Engine](./engine.md) — the loop that invokes the bound adapter each step
+83
View File
@@ -0,0 +1,83 @@
# Bundle
> A self-contained single-file ESM module (`.esm.js`) that implements one workflow, identified by its XXH64 hash encoded as 13-char Crockford Base32.
## Overview
A bundle is the physical unit of workflow distribution. Workflow authors build their TypeScript source into a single ESM file using `bun build` with `@uncaged/*` packages as externals. The resulting `.esm.js` is the artifact that gets registered and executed.
Every bundle is immutable and content-addressed: its identity is the XXH64 hash of its bytes, encoded as 13 characters of Crockford Base32 (e.g., `3TNKQRJ7BM4XH`). Registering a bundle with a new version simply adds a new hash entry; old hashes stay in the registry history and remain valid.
Bundles are stored on disk at `~/.uncaged/workflow/bundles/<hash>/` after registration. The `cas/` and `threads.json` for that bundle's execution state live under the same directory.
## Exports
Every valid bundle must export exactly two named exports — no default export is permitted:
| Export | Type | Description |
|--------|------|-------------|
| `run` | `WorkflowFn` | The `AsyncGenerator` that drives the execution loop |
| `descriptor` | `WorkflowDescriptor` | Serializable metadata: description, roles, and routing graph |
```typescript
// Minimal bundle shape
export const run: WorkflowFn = createWorkflow(def, binding);
export const descriptor: WorkflowDescriptor = buildDescriptor(def);
```
The validator in `@uncaged/workflow-register` enforces this contract before a bundle can be registered — see `extractBundleExports`.
## Hash Algorithm
The bundle hash is computed with **XXH64** (seed 0) over the raw bytes of the `.esm.js` file, then encoded as 13-char Crockford Base32 using `encodeUint64AsCrockford`:
```typescript
// packages/workflow-cas/src/hash.ts
export function hashWorkflowBundleBytes(data: Uint8Array): string {
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
const digest = XXH.h64(0).update(buf).digest();
return encodeUint64AsCrockford(digestToUint64(digest));
}
```
The same algorithm hashes CAS blob content (`hashString`), so all IDs in the system are consistent Crockford Base32 strings.
## Build Process
Bundles are not distributed from the monorepo directly. The typical flow is:
1. Create a separate workspace (e.g., `my-workflows/`) with `@uncaged/workflow-runtime` as a dependency.
2. Write a TypeScript workflow module that imports `createWorkflow` from `@uncaged/workflow-runtime`.
3. Run `bun build --entrypoints src/my-workflow.ts --outfile dist/my-workflow.esm.js --format esm --external '@uncaged/*'`.
4. Register with `uncaged-workflow workflow add <name> dist/my-workflow.esm.js`.
## Storage Layout
```
~/.uncaged/workflow/
workflow.yaml # registry (name → hash mapping)
bundles/
<hash>/
threads.json # active thread index
history/
YYYY-MM-DD.jsonl # completed thread records
cas/
<hash>.txt # CAS blobs (all bundles share one global CAS)
```
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-cas` | `src/hash.ts` | `hashWorkflowBundleBytes` and `hashString` — XXH64 + Crockford encoding |
| `@uncaged/workflow-register` | `src/bundle/extract-bundle-exports.ts` | Loads a `.esm.js` bundle and validates `run` + `descriptor` |
| `@uncaged/workflow-register` | `src/bundle/bundle-validator.ts` | Schema validation of bundle exports |
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — the primary bundle authoring function |
| `@uncaged/workflow-util` | `src/base32.ts` | `encodeUint64AsCrockford` — Crockford Base32 encoding |
| `@uncaged/workflow-util` | `src/storage-root.ts` | `getDefaultWorkflowStorageRoot``~/.uncaged/workflow` |
## See Also
- [Registry](./registry.md) — how bundles are registered and named in `workflow.yaml`
- [Thread](./thread.md) — how a bundle's `run` export is executed as a thread
- [Engine](./engine.md) — the executor that drives the bundle's `AsyncGenerator`
+111
View File
@@ -0,0 +1,111 @@
# CAS (Content-Addressable Storage)
> An append-only store where every blob is identified by its XXH64 hash, used to persist all workflow thread state as immutable Merkle nodes.
## Overview
CAS is the persistence substrate for the entire workflow engine. Rather than mutating a database row, every piece of state — agent output, role metadata, thread start parameters — is serialized as a YAML blob and stored under its hash. Because content determines identity, the same content always maps to the same hash, and writes are idempotent.
The `CasStore` interface is intentionally simple: `put`, `get`, `delete`, `list`. The default filesystem implementation stores each blob as `<hash>.txt` under `~/.uncaged/workflow/cas/`. Writes use an atomic rename-from-tmp pattern to prevent partial writes.
## Hash Algorithm
All hashes in the system are **XXH64** (seed 0) over UTF-8 content, encoded as 13-char Crockford Base32. This applies to both CAS blob hashes and bundle file hashes. The encoding function `encodeUint64AsCrockford` lives in `@uncaged/workflow-util`.
## Node Types
The CAS holds three types of YAML nodes, all sharing the `{ type, payload, refs }` envelope:
### `content` node
Stores the raw text output of an agent or the initial prompt. `refs` lists any artifact hashes the content references.
```yaml
type: content
payload: "The implementation is complete. Changed files: src/foo.ts"
refs:
- 3TNKQRJ7BM4XH # optional artifact refs
```
### `start` node
Written once when a thread begins. Anchors the thread to a specific workflow name, bundle hash, and depth level.
```yaml
type: start
payload:
name: solve-issue
hash: 3TNKQRJ7BM4XH
depth: 0
parentState: null
refs:
- <promptHash>
```
### `state` node
Written once per completed role step. Points back to the `start` node, the role's content node, and maintains an ancestor skip-list for traversal.
```yaml
type: state
payload:
role: coder
meta: { status: "done", completedPhase: "..." }
start: <startHash>
content: <contentHash>
ancestors: [<prev_state>, ...]
compact: null
timestamp: 1716000000000
childThread: null
refs:
- <contentHash>
- <startHash>
- <ancestor hashes>
```
## Merkle Structure
The `ancestors` array in each `StateNode` implements a **skip-list** capped at 11 entries (1 direct parent + up to 10 skip-list ancestors). This allows `O(log n)` traversal of the chain without loading every node, while keeping each blob self-contained.
```mermaid
graph LR
S[StartNode] --> C1[content₁]
N1[StateNode₁] --> S
N1 --> C1
N2[StateNode₂] --> N1
N2 --> S
N2 --> C2[content₂]
END[StateNode __end__] --> N2
END --> S
```
## CasStore Interface
```typescript
type CasStore = {
put(content: string): Promise<string>; // returns hash
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
```
`put` normalizes raw strings into `content` Merkle nodes before hashing; pre-serialized RFC v3 nodes pass through unchanged.
## Garbage Collection
`cas gc` performs a mark-and-sweep over all CAS blobs. It seeds the reachable set from `head` and `start` hashes in every `threads.json` and `history/*.jsonl`, then traverses `refs` edges transitively. Unreachable blobs are deleted. The result reports `scannedThreads`, `activeRefs`, and `deletedEntries`.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | `CasStore` interface definition |
| `@uncaged/workflow-protocol` | `src/cas-types.ts` | `StartNode`, `StateNode`, `ContentMerkleNode` types |
| `@uncaged/workflow-cas` | `src/cas.ts` | `createCasStore` — filesystem implementation |
| `@uncaged/workflow-cas` | `src/hash.ts` | `hashString`, `hashWorkflowBundleBytes` — XXH64 + Crockford |
| `@uncaged/workflow-cas` | `src/nodes.ts` | `putStartNode`, `putStateNode`, `putContentNodeWithRefs`, `parseCasThreadNode` |
| `@uncaged/workflow-cas` | `src/merkle.ts` | `parseMerkleNode`, `serializeMerkleNode`, `getContentMerklePayload` |
| `@uncaged/workflow-cas` | `src/reachable.ts` | Reachability traversal for GC |
| `@uncaged/workflow-execute` | `src/engine/gc.ts` | GC orchestration |
## See Also
- [Thread](./thread.md) — how thread execution state maps to CAS nodes
+107
View File
@@ -0,0 +1,107 @@
# CLI
> `uncaged-workflow` — the command-line tool for registering bundles, running threads, inspecting CAS, and connecting to the gateway.
## Overview
The CLI (`@uncaged/cli-workflow`) is the primary human interface to the workflow engine. It is a multi-level command dispatcher: top-level command groups (`workflow`, `thread`, `cas`, `init`, `setup`) each have a set of subcommands. Two shortcuts (`run`, `live`) alias frequently-used subcommands.
The storage root defaults to `~/.uncaged/workflow` and can be overridden with `WORKFLOW_STORAGE_ROOT` or `UNCAGED_WORKFLOW_STORAGE_ROOT` environment variables.
## Command Reference
### Workflow Registry (`workflow`)
| Subcommand | Args | Description |
|-----------|------|-------------|
| `workflow add` | `<name> <file.esm.js> [--types <path>]` | Register a workflow bundle in the registry |
| `workflow list` | | List all registered workflows |
| `workflow show` | `<name>` | Show bundle hash, timestamp, and descriptor |
| `workflow rm` | `<name>` | Remove a workflow from the registry |
| `workflow history` | `<name>` | Show version history for a workflow |
| `workflow rollback` | `<name> [hash]` | Roll back to a previous version |
### Thread Execution (`thread`)
| Subcommand | Args | Description |
|-----------|------|-------------|
| `thread run` | `<name> [--prompt <text>]` | Start a new thread for a workflow; prints thread ID |
| `thread list` | `[name]` | List threads, optionally filtered by workflow name |
| `thread show` | `<id>` | Show thread steps and state from CAS |
| `thread rm` | `<id>` | Remove a thread (from index and history) |
| `thread fork` | `<thread-id> [--from-role <role>]` | Fork from an existing thread |
| `thread ps` | | List running (active) threads |
| `thread kill` | `<thread-id>` | Send kill signal to a running thread |
| `thread live` | `<thread-id> \| --latest [--debug] [--role <name>]` | Attach and stream output live |
| `thread pause` | `<thread-id>` | Pause a running thread |
| `thread resume` | `<thread-id>` | Resume a paused thread |
### CAS Inspection (`cas`)
| Subcommand | Args | Description |
|-----------|------|-------------|
| `cas get` | `<hash>` | Print a CAS blob by hash |
| `cas put` | `<content>` | Store content in CAS, print hash |
| `cas list` | | List all hashes in CAS |
| `cas rm` | `<hash>` | Remove a CAS entry |
| `cas gc` | | Garbage-collect unreferenced entries |
### Other Commands
| Command | Args | Description |
|---------|------|-------------|
| `run <name> [...]` | | Shortcut for `thread run` |
| `live <id> [...]` | | Shortcut for `thread live` |
| `init` | | Scaffold a workflow workspace |
| `setup` | | Configure LLM providers in `workflow.yaml` |
| `connect [--name NAME] [--gateway URL]` | | Connect to gateway via WebSocket |
| `skill [topic]` | | Print agent-consumable docs (`cli`, `develop`, `author`) |
## Common Usage Examples
```bash
# Register a bundle
uncaged-workflow workflow add solve-issue dist/solve-issue.esm.js
# Run a workflow (prints thread ID)
uncaged-workflow run solve-issue --prompt "Fix the login bug in auth.ts"
# Watch live output
uncaged-workflow live <thread-id>
# Inspect a CAS blob
uncaged-workflow cas get 3TNKQRJ7BM4XH
# Show all running threads
uncaged-workflow thread ps
# Garbage-collect
uncaged-workflow cas gc
# Roll back to previous version
uncaged-workflow workflow rollback solve-issue
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `WORKFLOW_STORAGE_ROOT` | Override storage directory (default: `~/.uncaged/workflow`) |
| `UNCAGED_WORKFLOW_STORAGE_ROOT` | Internal override; takes priority over `WORKFLOW_STORAGE_ROOT` |
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/cli-workflow` | `src/cli-dispatch.ts` | Top-level command router (`COMMAND_TABLE`) |
| `@uncaged/cli-workflow` | `src/cli-usage.ts` | Usage text formatting |
| `@uncaged/cli-workflow` | `src/commands/workflow/dispatch.ts` | `WORKFLOW_SUBCOMMAND_TABLE` |
| `@uncaged/cli-workflow` | `src/commands/thread/dispatch.ts` | `THREAD_SUBCOMMAND_TABLE` |
| `@uncaged/cli-workflow` | `src/commands/cas/dispatch.ts` | `CAS_SUBCOMMAND_TABLE` |
| `@uncaged/cli-workflow` | `src/cli.ts` | CLI entry point |
## See Also
- [Bundle](./bundle.md) — what `workflow add` registers
- [Thread](./thread.md) — what `thread run` creates
- [Registry](./registry.md) — the `workflow.yaml` that `workflow` commands manage
+74
View File
@@ -0,0 +1,74 @@
# Dashboard
> A private React single-page application for browsing workflows, inspecting thread execution records, and triggering runs via a connected gateway.
## Overview
The dashboard (`workflow-dashboard`) is a read-mostly web UI that surfaces thread history and workflow metadata. It is a private package (not published to npm) and is deployed separately from the CLI. It communicates with one or more remote workflow engine instances through the `workflow-gateway` WebSocket gateway, which proxies API calls back to each connected CLI client.
The dashboard is not required to use the workflow engine — it is an optional observability layer on top of the same data that the CLI exposes.
## Tech Stack
| Concern | Choice |
|---------|--------|
| Framework | React (functional components, hooks) |
| Build | Vite |
| Styling | CSS variables via Tailwind-compatible utility classes |
| Charts/graphs | ReactFlow (workflow graph visualization) |
| HTTP | Native `fetch` with Bearer token auth |
| Transport | REST over HTTP (proxied through the gateway) |
## Data Sources
The dashboard consumes four REST endpoints per connected client (proxied by the gateway):
| Endpoint | Data |
|----------|------|
| `GET /workflows` | List of registered workflows with current hash and timestamp |
| `GET /workflows/:name` | Full workflow detail including `WorkflowDescriptor` and version history |
| `GET /threads` | All threads (active + completed) with summary fields |
| `GET /threads/:id` | Thread records: `ThreadStartRecord`, `RoleRecord[]`, `WorkflowResultRecord` |
The gateway multiplexes multiple CLI clients; the sidebar allows switching between them.
## Views
| View | Description |
|------|-------------|
| **Workflows** | Lists all registered workflows; clicking shows hash, descriptor, role graph, and version history |
| **Threads** | Lists all threads; clicking shows the full step-by-step execution record with role metadata |
| **Run dialog** | Form to start a new thread by picking a workflow and entering a prompt |
### Workflow Graph
Each workflow's `WorkflowDescriptor.graph` is rendered as an interactive ReactFlow diagram. Nodes represent roles (plus `__start__` and `__end__` terminals); edges represent moderator transitions labeled with condition names.
## Authentication
A Bearer token (stored in `localStorage` under `workflow-api-key`) is sent with every API request. The login page prompts for this key on first load. The gateway validates the token before proxying requests to connected clients.
## Gateway Connection
`uncaged-workflow connect [--name NAME] [--gateway URL]` registers the local workflow engine as a named client with the gateway over a WebSocket. The gateway then forwards REST API calls from the dashboard to the connected CLI process. The dashboard calls `GET /api/gateway/endpoints` to discover connected clients.
## Private App Status
`workflow-dashboard` has `"private": true` in its `package.json` and is excluded from the changeset versioning pipeline. It is developed alongside the engine packages but distributed separately (e.g., as a static build hosted alongside the gateway server).
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `workflow-dashboard` | `src/app.tsx` | Root component — routing, auth state, view switching |
| `workflow-dashboard` | `src/api.ts` | All API functions + endpoint types (`ThreadRecord`, `WorkflowDetail`, etc.) |
| `workflow-dashboard` | `src/components/thread-detail.tsx` | Thread step viewer |
| `workflow-dashboard` | `src/components/workflow-graph/workflow-graph.tsx` | ReactFlow graph of workflow roles and transitions |
| `workflow-dashboard` | `src/components/sidebar.tsx` | Client selector and view navigation |
| `@uncaged/workflow-gateway` | `src/index.ts` | Gateway server entry point |
| `@uncaged/workflow-gateway` | `src/ws-protocol.ts` | WebSocket message protocol between CLI and gateway |
## See Also
- [Thread](./thread.md) — the execution records the dashboard displays
- [Engine](./engine.md) — the process that produces those records
+110
View File
@@ -0,0 +1,110 @@
# Engine
> The execution loop that drives a workflow bundle's `AsyncGenerator`, persisting each yielded `RoleOutput` as a CAS `StateNode` and managing thread lifecycle.
## Overview
The engine (`executeThread`) takes a `WorkflowFn` and runs it to completion. It is responsible for three concerns: persisting each role output to CAS, updating the active-thread index after every step, and terminating the thread cleanly when the generator finishes, is aborted, or is killed by the supervisor.
The engine does not interact with LLMs directly — that responsibility belongs to the workflow bundle's `run` function and its bound agent adapters. The engine only observes `RoleOutput` values yielded by the generator.
## Execution Flow
```mermaid
flowchart TD
A[executeThread] --> B[putStartNode → CAS]
B --> C[publishHead → threads.json]
C --> D{generator.next}
D -- done --> E[finalizeThread]
D -- yield RoleOutput --> F[appendStateForStep → CAS]
F --> G[publishHead → threads.json]
G --> H{supervisorInterval?}
H -- kill --> E
H -- continue --> I{awaitAfterEachYield}
I --> D
D -- AbortSignal --> J[finalizeAbortedThread]
E --> K[removeThreadEntry]
K --> L[appendThreadHistoryEntry]
```
## Role Loop (inside the bundle's `createWorkflow`)
The `WorkflowFn` produced by `createWorkflow` runs its own loop — one iteration per role step:
1. **Moderator**: calls `pickNext(ctx)` (derived from the `ModeratorTable`) → returns a role name or `END`.
2. **Adapter**: calls the bound `AdapterFn` with the role's `systemPrompt` and Zod schema → returns `RoleFn` → executes → returns `RoleResult<T>`.
3. **Persist**: calls `putContentNodeWithRefs` to store the role output in CAS, constructs a `RoleStep`, and `yield`s a `RoleOutput` to the engine.
```mermaid
sequenceDiagram
participant E as Engine
participant W as WorkflowFn (bundle)
participant M as Moderator
participant A as AdapterFn
participant C as CAS
E->>W: generator.next()
W->>M: pickNext(ctx) → roleName
W->>A: adapter(systemPrompt, schema)(ctx, runtime)
A-->>W: RoleResult { meta, childThread }
W->>C: putContentNodeWithRefs(JSON.stringify(meta))
W-->>E: yield RoleOutput
E->>C: putStateNode(StateNodePayload)
E->>E: publishHead(threads.json)
```
## Key Types
```typescript
// Engine input
type ExecuteThreadOptions = {
depth: number;
parentStateHash: string | null;
signal: AbortSignal;
awaitAfterEachYield: () => Promise<void>; // used for pause/resume gate
forkContinuation: ForkContinuationOptions | null;
prefilledDiskSteps: PrefilledDiskStep[] | null;
replayTimestamps: readonly number[] | null;
storageRoot: string;
};
// Engine output
type WorkflowResult = {
returnCode: number;
summary: string;
rootHash: string; // hash of the __end__ StateNode
};
```
## Pause Gate
`awaitAfterEachYield` is a function injected by the worker/runner that can block the loop between steps. The `ThreadPauseGate` in `thread-pause-gate.ts` provides `pause()` / `resume()` operations that control this gate. When paused, the loop suspends after writing the current step but before requesting the next one.
## Supervisor
If `workflowConfig.supervisorInterval > 0`, the engine runs a supervisor check after every `supervisorInterval` steps. The supervisor calls an LLM with a summary of recent steps and returns `"continue"` or `"kill"`. A `"kill"` decision finalizes the thread immediately with `returnCode: 1` and a summary string.
## Summarizer
On normal completion (generator returns), the engine calls `createSummarizer` to produce a single LLM-generated summary string from recent step content. This summary replaces the bundle's raw `WorkflowCompletion.summary` in the final history record.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-execute` | `src/engine/engine.ts` | `executeThread` — main engine entry point |
| `@uncaged/workflow-execute` | `src/engine/types.ts` | `ExecuteThreadOptions`, `ExecuteThreadIo`, `ChainState`, `ThreadPauseGate` |
| `@uncaged/workflow-execute` | `src/engine/threads-index.ts` | `threads.json` persistence, history append |
| `@uncaged/workflow-execute` | `src/engine/supervisor.ts` | Supervisor LLM check (`"continue"` / `"kill"`) |
| `@uncaged/workflow-execute` | `src/engine/summarizer.ts` | Post-completion LLM summary |
| `@uncaged/workflow-execute` | `src/engine/thread-pause-gate.ts` | Pause/resume gate |
| `@uncaged/workflow-execute` | `src/engine/worker.ts` | Worker-process entry that spawns `executeThread` in a subprocess |
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — the role loop inside the bundle |
| `@uncaged/workflow-protocol` | `src/types.ts` | `WorkflowFn`, `RoleOutput`, `WorkflowCompletion`, `AdvanceOutcome` |
## See Also
- [Role](./role.md) — what the moderator selects each iteration
- [Agent Binding](./agent-binding.md) — what executes a role and returns its output
- [Reactor](./reactor.md) — used internally for the extract and supervisor LLM calls
- [Thread](./thread.md) — the CAS-persisted result of running the engine
+129
View File
@@ -0,0 +1,129 @@
# Package Map
> All packages in the monorepo with their responsibilities, dependency layers, and publication status.
## Overview
The monorepo is organized as a strict dependency DAG. Each layer may only depend on layers below it. The execution stack flows from the shared protocol types at the bottom up to the CLI at the top. Agent packages and template packages are leaf nodes that depend on the runtime layer but are not depended upon by the core stack.
## Package List
| Package | Description |
|---------|-------------|
| `@uncaged/workflow-protocol` | Shared types (`ThreadContext`, `RoleDefinition`, `CasStore`, `Result`, etc.) and constants (`START`, `END`) |
| `@uncaged/workflow-runtime` | `createWorkflow`, type re-exports; primary dependency for bundle authors |
| `@uncaged/workflow-util` | Utilities: Crockford Base32, ULID, structured logger, storage paths |
| `@uncaged/workflow-reactor` | `createThreadReactor` (ReAct loop), `createLlmFn` (OpenAI-compatible LLM caller) |
| `@uncaged/workflow-cas` | `createCasStore` (filesystem CAS), XXH64 hashing, Merkle node serialization |
| `@uncaged/workflow-register` | Bundle validation, `workflow.yaml` registry read/write, model resolution |
| `@uncaged/workflow-execute` | Engine (`executeThread`), extract phase, fork, GC, `workflowAsAgent` |
| `@uncaged/cli-workflow` | `uncaged-workflow` CLI — command dispatcher for all user-facing operations |
| `@uncaged/workflow-agent-cursor` | Adapter that runs the `cursor` CLI non-interactively in a workspace |
| `@uncaged/workflow-agent-hermes` | Adapter that runs `hermes chat` (Nerve-style CLI agent) |
| `@uncaged/workflow-agent-llm` | Adapter for direct LLM chat completions |
| `@uncaged/workflow-agent-react` | Adapter with ReAct loop and file/shell tools |
| `@uncaged/workflow-util-agent` | Shared agent utilities: `buildThreadInput`, `spawnCli`, `createAgentAdapter` |
| `@uncaged/workflow-template-develop` | `develop` workflow template (planner → coder → reviewer → tester → committer) |
| `@uncaged/workflow-template-solve-issue` | `solve-issue` workflow template (preparer → developer → submitter) |
| `@uncaged/workflow-gateway` | WebSocket gateway for remote CLI-to-dashboard communication |
| `workflow-dashboard` | React dashboard (private, unpublished) — thread/workflow viewer |
## Dependency Layer Diagram
```mermaid
graph TD
subgraph Layer 0 — Protocol
P[workflow-protocol]
end
subgraph Layer 1 — Foundations
RT[workflow-runtime]
UT[workflow-util]
RX[workflow-reactor]
end
subgraph Layer 2 — Storage & Register
CAS[workflow-cas]
REG[workflow-register]
end
subgraph Layer 3 — Execute
EX[workflow-execute]
end
subgraph Layer 4 — CLI
CLI[cli-workflow]
end
subgraph Agents (leaf)
AGC[workflow-agent-cursor]
AGH[workflow-agent-hermes]
AGL[workflow-agent-llm]
AGR[workflow-agent-react]
UA[workflow-util-agent]
end
subgraph Templates (leaf)
TD[workflow-template-develop]
TS[workflow-template-solve-issue]
end
subgraph Dashboard
GW[workflow-gateway]
DB[workflow-dashboard]
end
RT --> P
UT --> P
RX --> P
CAS --> P
REG --> P
REG --> UT
EX --> RT
EX --> UT
EX --> CAS
EX --> REG
EX --> RX
CLI --> EX
CLI --> UT
CLI --> REG
AGC --> RT
AGC --> UT
AGC --> UA
AGH --> RT
AGH --> UA
AGL --> RT
AGR --> RT
AGR --> RX
UA --> RT
TD --> RT
TS --> RT
DB --> GW
```
## Published vs. Private
All `@uncaged/*` packages are published to **npmjs.org** under a fixed versioning scheme (all packages share the same version number via `@changesets/cli` in fixed mode).
| Status | Packages |
|--------|---------|
| **Published** | All packages with `@uncaged/` scope |
| **Private** | `workflow-dashboard` (no `@uncaged/` scope, `"private": true`) |
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | Root type definitions for the entire stack |
| `@uncaged/workflow-runtime` | `src/index.ts` | Public API for bundle authors |
| `@uncaged/workflow-util` | `src/index.ts` | Utility re-exports |
| `@uncaged/workflow-execute` | `src/index.ts` | Engine public API |
| `@uncaged/cli-workflow` | `src/cli-dispatch.ts` | Top-level command table |
## See Also
- [Bundle](./bundle.md) — produced by workspace authors using `@uncaged/workflow-runtime`
- [Engine](./engine.md) — the core of `@uncaged/workflow-execute`
- [Reactor](./reactor.md) — `@uncaged/workflow-reactor`
- [Registry](./registry.md) — `@uncaged/workflow-register`
- [CLI](./cli.md) — `@uncaged/cli-workflow`
+102
View File
@@ -0,0 +1,102 @@
# Reactor
> A generic ReAct (Reason + Act) loop that drives an LLM through multiple tool-call rounds until it produces structured output matching a Zod schema.
## Overview
The reactor is a reusable abstraction for LLM interactions that require tool use. It runs a multi-turn conversation loop: the LLM is presented with a user message and a set of tools, and responds either with a tool call (which the reactor dispatches and feeds back) or with a plain JSON object matching the expected schema. The loop repeats until structured output is obtained or `maxRounds` is exhausted.
The reactor is used in two places:
1. **Extract phase**`createExtract` in `@uncaged/workflow-execute` uses a CAS-backed reactor to extract typed `meta` from a role's content hash.
2. **React agent**`createReactAdapter` in `@uncaged/workflow-agent-react` uses the reactor as its execution backbone.
## createThreadReactor
```typescript
function createThreadReactor<TThread>(
config: ThreadReactorConfig<TThread>,
): ThreadReactorFn<TThread>
```
`ThreadReactorConfig` bundles:
| Field | Purpose |
|-------|---------|
| `llm` | The `LlmFn` to call each round |
| `staticTools` | Tools always available (e.g., `cas_get`) |
| `structuredToolFromSchema` | Derives a schema-specific extraction tool from the Zod schema |
| `systemPromptForStructuredTool` | Constructs the system prompt given the extraction tool name |
| `toolHandler` | Handles non-structured tool calls; receives the raw `ToolCall` and thread context |
| `maxRounds` | Hard stop after N rounds; returns `err("max_react_rounds_exceeded")` |
## Round Lifecycle
```mermaid
sequenceDiagram
participant R as Reactor
participant L as LLM
participant H as toolHandler
R->>L: messages + tools
L-->>R: response
alt plain JSON (valid schema)
R-->>R: return ok(value)
else plain JSON (invalid)
R->>L: correction message
else tool_calls
loop each call
alt structured tool
R-->>R: validate args → return ok(value)
else static tool
R->>H: toolHandler(call, thread)
H-->>R: content string
R->>L: tool result message
end
end
end
```
## LlmFn
```typescript
type LlmFn = (input: {
messages: ChatMessage[];
tools: readonly ToolDefinition[];
}) => Promise<Result<string, string>>;
```
`createLlmFn(provider)` in `@uncaged/workflow-reactor` builds an `LlmFn` that calls the OpenAI-compatible chat completions endpoint and returns the raw response body as a string for the reactor to parse.
## Extract Phase
`createExtract(provider, { cas })` in `@uncaged/workflow-execute` creates a `CasReactor` — a preconfigured `ThreadReactorFn` with a `cas_get` static tool. The extract function loads the content payload for a given hash, sends it to the reactor with the role's Zod schema, and returns `ExtractResult<T>`.
```typescript
type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
contentHash: string,
) => Promise<ExtractResult<T>>;
```
The `cas_get` tool allows the LLM to dereference CAS hashes during extraction — important when the content node references artifact hashes.
## Relationship to Engine
The reactor is called within `AdapterFn` implementations (e.g., `createLlmAdapter`, `createReactAdapter`) when the agent needs multi-turn tool interaction to complete a role. The engine itself does not call the reactor directly — it only drives the outer `WorkflowFn` generator and persists `RoleOutput` values.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-reactor` | `src/thread-reactor.ts` | `createThreadReactor` — generic ReAct loop |
| `@uncaged/workflow-reactor` | `src/llm-fn.ts` | `createLlmFn` — OpenAI-compatible LLM caller |
| `@uncaged/workflow-reactor` | `src/types.ts` | `LlmFn`, `ThreadReactorConfig`, `ToolCall`, `ToolDefinition`, `ChatMessage` |
| `@uncaged/workflow-execute` | `src/cas-reactor.ts` | `createCasReactor` — reactor with `cas_get` static tool |
| `@uncaged/workflow-execute` | `src/extract/extract-fn.ts` | `createExtract` — extract phase using the CAS reactor |
## See Also
- [Engine](./engine.md) — drives the workflow generator; extract is called inside the adapter layer
- [Agent Binding](./agent-binding.md) — adapter implementations that use the reactor internally
+95
View File
@@ -0,0 +1,95 @@
# Registry
> `workflow.yaml` — the local file that maps workflow names to their current and historical bundle hashes, plus global LLM provider configuration.
## Overview
The registry is a single YAML file at `<storageRoot>/workflow.yaml` (default: `~/.uncaged/workflow/workflow.yaml`). It is the authoritative index of which bundles are available on a machine and what name each one is known by. All CLI workflow commands read or write this file.
The registry is read on every `uncaged-workflow run` invocation to look up the bundle hash for a given name, then used again to resolve the `extract` model configuration. It is written atomically via the `writeWorkflowRegistry` function.
## Schema
```yaml
config:
maxDepth: 3
supervisorInterval: 5
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKey: "sk-or-..."
models:
extract: "openrouter/anthropic/claude-sonnet-4-5"
supervisor: "openrouter/anthropic/claude-haiku-3-5"
workflows:
solve-issue:
hash: "3TNKQRJ7BM4XH"
timestamp: 1716000000000
history:
- hash: "2BMJPQ6YAK3WG"
timestamp: 1715000000000
develop:
hash: "7VQWX8NRHK1ZT"
timestamp: 1716100000000
history: []
```
## Types
```typescript
type WorkflowRegistryFile = {
config: WorkflowConfig | null;
workflows: Record<string, WorkflowRegistryEntry>;
};
type WorkflowRegistryEntry = {
hash: string; // current bundle hash (13-char Crockford Base32)
timestamp: number; // Unix epoch ms when this version was registered
history: WorkflowHistoryEntry[];
};
type WorkflowHistoryEntry = {
hash: string;
timestamp: number;
};
```
## Bundle Registration Flow
1. `uncaged-workflow workflow add <name> <file.esm.js>` is called.
2. The bundle bytes are hashed with XXH64 → 13-char Crockford Base32.
3. The bundle file is copied into `<storageRoot>/bundles/<hash>/` (if not already present).
4. `registerWorkflowVersion` prepends the current head to `history` and sets the new hash as head.
5. The updated registry is written back to `workflow.yaml`.
## Version History
Every `workflow add` on an already-registered name pushes the previous hash into `history`. History is ordered most-recent-first. `workflow rollback <name> [hash]` swaps the specified history entry back to head (or defaults to `history[0]`).
## Model Resolution
The `config.models` section uses `provider/model` references (e.g., `"openrouter/anthropic/claude-sonnet-4-5"`). `resolveModel` splits the reference on the first `/`, looks up the provider in `config.providers`, and returns a `ResolvedModel` with `{ baseUrl, apiKey, model }`. This is used by the engine to configure the `extract` LLM.
```typescript
// packages/workflow-register/src/config/resolve-model.ts
export function resolveModel(
config: WorkflowConfig,
modelKey: string,
): Result<ResolvedModel, string>
```
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-register` | `src/registry/registry.ts` | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `rollbackWorkflowToHistoryHash` |
| `@uncaged/workflow-register` | `src/registry/types.ts` | `WorkflowRegistryFile`, `WorkflowRegistryEntry`, `WorkflowHistoryEntry` |
| `@uncaged/workflow-register` | `src/registry/registry-normalize.ts` | YAML normalization for the registry root |
| `@uncaged/workflow-register` | `src/config/resolve-model.ts` | `resolveModel` — splits `provider/model` refs |
| `@uncaged/workflow-register` | `src/bundle/extract-bundle-exports.ts` | Validates bundle exports before registration |
| `@uncaged/workflow-protocol` | `src/types.ts` | `WorkflowConfig`, `ProviderConfig`, `ResolvedModel` |
## See Also
- [Bundle](./bundle.md) — what is stored and indexed in the registry
+72
View File
@@ -0,0 +1,72 @@
# Role
> A named actor within a workflow defined entirely as pure data — a description, a system prompt, an extraction schema, and an optional refs extractor — with no embedded agent logic.
## Overview
A role is a `RoleDefinition<Meta>` value: a plain TypeScript object that describes what an actor in the workflow does and how its output should be structured. Roles are authored in the template or bundle source and passed to `createWorkflow` as part of the `WorkflowDefinition`. They never hold a reference to an agent implementation.
This separation of concerns is deliberate. The same role definition can be executed by different agents (Cursor, Hermes, an LLM, a React loop) simply by changing the `AdapterBinding` passed to `createWorkflow`. Roles are also serialized into the `WorkflowDescriptor` for tooling like the dashboard.
## RoleDefinition Type
```typescript
type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
schema: z.ZodType<Meta>;
extractRefs: ((meta: Meta) => string[]) | null;
};
```
| Field | Purpose |
|-------|---------|
| `description` | Human-readable summary for tooling and the `WorkflowDescriptor` |
| `systemPrompt` | Passed to the adapter as the agent's persona/instruction for this role |
| `schema` | Zod v4 schema that defines the structured output (`Meta`) of the role |
| `extractRefs` | Optional function that extracts CAS hashes from `meta` to record as artifact refs |
## Schema and Extraction
Each role's `schema` is a Zod v4 type parameterized to the role's `Meta` type. When a role executes via an `AdapterFn`, the adapter is responsible for producing a value that satisfies this schema directly (the `AdapterFn` receives the schema and system prompt and returns a `RoleFn` that yields `RoleResult<T>`).
If `extractRefs` is non-null, the engine calls it on the completed `meta` to collect additional CAS hashes that should appear in the `StateNode.refs` skip-list, enabling traversal of artifacts produced by the role.
## WorkflowDefinition
Roles are collected into a `WorkflowDefinition<M>` alongside the moderator table:
```typescript
type WorkflowDefinition<M extends RoleMeta> = {
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
table: ModeratorTable<M>;
};
```
`M` is the `RoleMeta` map that binds each role name to its concrete `Meta` type. This gives full TypeScript type safety across the moderator, adapter, and CAS storage layers.
## WorkflowRoleDescriptor (Serialized)
The `WorkflowDescriptor` (stored in the bundle's `descriptor` export) contains a `roles` map of `WorkflowRoleDescriptor` objects — a JSON-serializable projection of each `RoleDefinition`:
```typescript
type WorkflowRoleDescriptor = {
description: string;
systemPrompt: string;
schema: WorkflowRoleSchema; // JSON-compatible schema shape
};
```
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | `RoleDefinition`, `WorkflowDefinition`, `RoleMeta`, `WorkflowRoleDescriptor`, `WorkflowDescriptor` |
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | Consumes `WorkflowDefinition` roles in the adapter dispatch loop |
| `@uncaged/workflow-register` | `src/bundle/build-descriptor.ts` | Serializes `RoleDefinition[]` to `WorkflowDescriptor` |
## See Also
- [Engine](./engine.md) — the loop that selects and executes roles
- [Agent Binding](./agent-binding.md) — the runtime binding that executes a role via a concrete agent
+97
View File
@@ -0,0 +1,97 @@
# Thread
> A single execution instance of a workflow, identified by a ULID, whose state is stored as a linked chain of immutable CAS nodes.
## Overview
A thread is the runtime envelope around one call to a workflow's `run` function. It carries a unique ULID (26-char Crockford Base32) and tracks the full sequence of role steps that have executed. Because all state is written to CAS as immutable blobs, threads are append-only and fully auditable.
Every thread belongs to a specific workflow bundle (identified by hash). The engine writes a `StartNode` when the thread begins and one `StateNode` per completed role step — including a final `__end__` state on completion or abort. Steps accumulate in `ThreadContext.steps` and are replayed into the context whenever a thread is resumed.
## Lifecycle
```mermaid
stateDiagram-v2
[*] --> Active: thread run / fork
Active --> Active: role step yielded
Active --> Paused: pause signal
Paused --> Active: resume signal
Active --> Completed: generator returns WorkflowCompletion
Active --> Aborted: kill signal / AbortSignal
Completed --> [*]: entry in history/*.jsonl
Aborted --> [*]: entry in history/*.jsonl (returnCode=130)
```
## Identity
Thread IDs are ULIDs: 26-char Crockford Base32 strings composed of a 10-char timestamp prefix and a 16-char random suffix. Generated by `generateUlid` from `@uncaged/workflow-util`.
## State Storage
Thread state is stored entirely in CAS as a linked list of nodes:
```
StartNode (type: "start")
payload: { name, hash, depth, parentState }
refs: [promptHash, parentState?]
StateNode (type: "state") ← one per role step
payload: { role, meta, start, content, ancestors[], compact, timestamp, childThread }
refs: [contentHash, startHash, ancestor hashes...]
StateNode (type: "state", role: "__end__") ← final node
payload: { returnCode, summary }
```
The `ancestors` array implements a skip-list (capped at 11 entries: 1 direct parent + up to 10 ancestors) to allow efficient traversal without loading every node in the chain.
## Index Files
| File | Purpose |
|------|---------|
| `<bundleDir>/threads.json` | Active thread index — maps `threadId → { head, start, updatedAt }` |
| `<bundleDir>/history/YYYY-MM-DD.jsonl` | Completed thread records — one JSON line per completed/aborted thread |
| `<storageRoot>/cas/` | All CAS blobs shared across all bundles |
A thread is "active" while it appears in `threads.json`. On completion, its entry is removed from `threads.json` and a record appended to the appropriate `history/*.jsonl` file.
## ThreadContext
The `ThreadContext` type is the read-only view passed into every role and moderator call:
```typescript
type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
depth: number;
bundleHash: string;
start: StartStep;
steps: RoleStep<M>[];
};
```
`depth` tracks nesting for sub-workflow invocations (workflow-as-agent). `steps` grows by one entry after each successful role execution.
## Fork
A thread can be forked from any completed role step via `thread fork <id> [--from-role <role>]`. The fork reuses the original `StartNode` (same `startHash`) and replays CAS steps up to the fork point before resuming the generator. The forked thread gets a new ULID.
## Debug Logs
Each thread writes structured JSONL debug logs to `.info.jsonl` in the bundle directory. Each log line is `{ tag, content, timestamp }` where `tag` is an 8-char Crockford Base32 call-site identifier.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | `ThreadContext`, `StartStep`, `RoleStep`, `RoleMeta` types |
| `@uncaged/workflow-protocol` | `src/cas-types.ts` | `StartNode`, `StartNodePayload`, `StateNode`, `StateNodePayload` |
| `@uncaged/workflow-execute` | `src/engine/threads-index.ts` | `threads.json` read/write, history append, `ThreadIndexEntry` |
| `@uncaged/workflow-execute` | `src/engine/engine.ts` | `executeThread` — starts, drives, and finalizes a thread |
| `@uncaged/workflow-execute` | `src/engine/fork-thread.ts` | Fork logic |
| `@uncaged/workflow-util` | `src/ulid.ts` | `generateUlid` — ULID generation |
## See Also
- [CAS](./cas.md) — the storage layer that holds all thread state nodes
- [Engine](./engine.md) — the execution loop that drives the thread
- [Bundle](./bundle.md) — the workflow being executed in this thread
+153
View File
@@ -0,0 +1,153 @@
# Workflow Templates
> Pre-built `WorkflowDefinition` objects exported from `@uncaged/workflow-template-*` packages that bundle authors can import, customize, or use directly.
## Overview
Templates are the reference implementations of common workflow patterns. They export a complete `WorkflowDefinition<M>` — typed roles with Zod schemas, and a `ModeratorTable` — ready to be passed to `createWorkflow`. A bundle author imports a template definition, supplies an `AdapterBinding`, calls `createWorkflow`, and exports the result as `run`.
Templates are published as regular `@uncaged/*` npm packages. They are not bundles themselves; they are TypeScript libraries that become part of a bundle when the author's workspace is built.
## solve-issue Template
**Package**: `@uncaged/workflow-template-solve-issue`
Resolves an issue end-to-end by preparing the repository, delegating implementation to a nested `develop` workflow, and opening a pull request.
### Roles
| Role | Description |
|------|-------------|
| `preparer` | Reads the issue, clones/checks out the repo, sets up the environment |
| `developer` | Delegates to the `develop` workflow via `workflowAsAgent` (child thread) |
| `submitter` | Opens a pull request with the completed changes |
### Moderator Table
```
__start__ → preparer → developer → submitter → __end__
```
Linear routing — each role runs exactly once in sequence.
### Meta Types
```typescript
type SolveIssueMeta = {
preparer: PreparerMeta;
developer: DeveloperMeta;
submitter: SubmitterMeta;
};
```
## develop Template
**Package**: `@uncaged/workflow-template-develop`
Plans an implementation in phases, codes each phase incrementally, reviews, verifies with tests/build/lint, and commits.
### Roles
| Role | Description |
|------|-------------|
| `planner` | Produces an ordered list of implementation phases with hashes |
| `coder` | Implements one phase; reports `completedPhase` hash in meta |
| `reviewer` | Reviews the accumulated changes; approves or requests changes |
| `tester` | Runs tests/lint/build; reports `passed` or `failed` |
| `committer` | Creates the final git commit |
### Moderator Table
```
__start__ → planner
planner → __end__ (if status == "aborted")
planner → coder (fallback)
coder → reviewer (if allPhasesComplete)
coder → coder (fallback — repeat per phase)
reviewer → tester (if status == "approved")
reviewer → coder (fallback — request changes)
tester → committer (if status == "passed")
tester → coder (fallback — fix failures)
committer → __end__
```
### Meta Types
```typescript
type DevelopMeta = {
planner: PlannerMeta;
coder: CoderMeta;
reviewer: ReviewerMeta;
tester: TesterMeta;
committer: CommitterMeta;
};
```
## Writing a Custom Template
A minimal custom workflow:
```typescript
import { createWorkflow, type WorkflowDefinition, END, START } from "@uncaged/workflow-runtime";
import { z } from "zod/v4";
import type { AdapterBinding } from "@uncaged/workflow-runtime";
type MyMeta = {
analyst: { summary: string; confidence: number };
writer: { report: string };
};
const def: WorkflowDefinition<MyMeta> = {
description: "Analyse then write a report.",
roles: {
analyst: {
description: "Analyses the input and produces a structured summary.",
systemPrompt: "You are an expert analyst...",
schema: z.object({ summary: z.string(), confidence: z.number() }),
extractRefs: null,
},
writer: {
description: "Writes the final report.",
systemPrompt: "You are a technical writer...",
schema: z.object({ report: z.string() }),
extractRefs: null,
},
},
table: {
[START]: [{ condition: "FALLBACK", role: "analyst" }],
analyst: [{ condition: "FALLBACK", role: "writer" }],
writer: [{ condition: "FALLBACK", role: END }],
},
};
// In the bundle entry point:
export const run = createWorkflow(def, binding);
export const descriptor = buildDescriptor(def);
```
## Template → Bundle Relationship
Templates are TypeScript library packages, not bundles. To use a template:
1. Install the template package from npm: `bun add @uncaged/workflow-template-develop`.
2. Import the definition: `import { developWorkflowDefinition } from "@uncaged/workflow-template-develop"`.
3. Supply an `AdapterBinding` and call `createWorkflow`.
4. Build with `bun build` to produce `.esm.js`.
5. Register with `uncaged-workflow workflow add`.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-template-solve-issue` | `src/index.ts` | `solveIssueWorkflowDefinition`, role and moderator exports |
| `@uncaged/workflow-template-solve-issue` | `src/roles.ts` | `SolveIssueMeta`, `solveIssueRoles` |
| `@uncaged/workflow-template-solve-issue` | `src/moderator.ts` | `solveIssueTable` — linear transition table |
| `@uncaged/workflow-template-develop` | `src/index.ts` | `developWorkflowDefinition`, role and moderator exports |
| `@uncaged/workflow-template-develop` | `src/roles.ts` | `DevelopMeta`, `developRoles` |
| `@uncaged/workflow-template-develop` | `src/moderator.ts` | `developTable` — conditional multi-phase table |
## See Also
- [Bundle](./bundle.md) — the build artifact produced from a template + adapter
- [Role](./role.md) — the `RoleDefinition` type each template role implements
- [Engine](./engine.md) — the execution loop that drives the template's `WorkflowFn`
+8
View File
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [["@uncaged/*"]],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@uncaged/workflow-dashboard"]
}
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-util": patch
---
Replace optionalEnv/requireEnv with unified env(name, fallback) API
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-protocol": patch
---
fix: correct internal dependency versions for prerelease
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-util-agent": patch
---
fix: include create-agent-adapter.ts in published src
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-protocol": patch
---
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
+30
View File
@@ -0,0 +1,30 @@
{
"mode": "pre",
"tag": "alpha",
"initialVersions": {
"@uncaged/cli-workflow": "0.4.5",
"@uncaged/workflow-agent-cursor": "0.4.5",
"@uncaged/workflow-agent-hermes": "0.4.5",
"@uncaged/workflow-agent-llm": "0.4.5",
"@uncaged/workflow-agent-react": "0.4.5",
"@uncaged/workflow-cas": "0.4.5",
"@uncaged/workflow-dashboard": "0.1.0",
"@uncaged/workflow-execute": "0.4.5",
"@uncaged/workflow-gateway": "0.4.5",
"@uncaged/workflow-protocol": "0.4.5",
"@uncaged/workflow-reactor": "0.4.5",
"@uncaged/workflow-register": "0.4.5",
"@uncaged/workflow-runtime": "0.4.5",
"@uncaged/workflow-template-develop": "0.4.5",
"@uncaged/workflow-template-solve-issue": "0.4.5",
"@uncaged/workflow-util": "0.4.5",
"@uncaged/workflow-util-agent": "0.4.5"
},
"changesets": [
"env-api-unify",
"fix-internal-deps",
"fix-publish-src",
"fix-workspace-deps",
"rfc-252-agent-fn"
]
}
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-protocol": minor
---
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
+40
View File
@@ -0,0 +1,40 @@
# ──────────────────────────────────────────────
# Workflow Engine — Environment Variables
# ──────────────────────────────────────────────
# Copy this file to .env and fill in the values.
# ── Cursor Agent ──
# CLI command to invoke the Cursor agent (required for develop workflow)
WORKFLOW_CURSOR_COMMAND=
# Model override for Cursor agent
WORKFLOW_CURSOR_MODEL=
# Timeout in milliseconds for Cursor agent operations
WORKFLOW_CURSOR_TIMEOUT=
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
# CLI command to invoke the Hermes agent (absolute path required)
WORKFLOW_HERMES_COMMAND=
# Model override for Hermes agent
WORKFLOW_HERMES_MODEL=
# Timeout in milliseconds for Hermes agent operations
WORKFLOW_HERMES_TIMEOUT=
# ── Storage ──
# Override the workflow storage root directory
# Default: ~/.uncaged/workflow
WORKFLOW_STORAGE_ROOT=
# Gateway secret for the serve command
WORKFLOW_DASHBOARD_SECRET=
# ── Display ──
# Set to any value to disable colored output
# NO_COLOR=1
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
echo "🔍 Running check (tsc + biome + lint-log-tags)..."
bun run check
echo "🧪 Running tests..."
bun run test
echo "✅ All checks passed!"
+3
View File
@@ -6,3 +6,6 @@ tsconfig.tsbuildinfo
.npmrc
bunfig.toml
xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
+22 -35
View File
@@ -30,6 +30,7 @@ workflow/
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
workflow-agent-llm/ # @uncaged/workflow-agent-llm
workflow-agent-react/ # @uncaged/workflow-agent-react
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
workflow-template-develop/ # @uncaged/workflow-template-develop
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
@@ -40,7 +41,7 @@ workflow/
```
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute``cli-workflow`
- Packages use `workspace:*` protocol
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
## Language & Paradigm
@@ -245,61 +246,47 @@ bun run format # biome format --write
bun test # run tests
```
### Publishing to Gitea npm Registry
### Version Management & Publishing
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`.
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
```bash
# Publish all packages (bun pm pack resolves workspace:* → actual versions)
bun run publish:gitea
# 1. After making changes, add a changeset describing the change
bun changeset
# Dry run — see what would be published
bun run publish:gitea:dry
# 2. Before release, bump all package versions + generate CHANGELOGs
bun version
# 3. Build, test, and publish to npmjs
bun release
```
Prerequisites: `.npmrc` in monorepo root with Gitea auth token (`//git.shazhou.work/api/packages/shazhou/npm/:_authToken=<token>`).
- `workspace:^` dependencies resolve to `^x.y.z` on publish
- Changesets config: `.changeset/config.json` (fixed mode, public access)
- Each package has auto-generated `CHANGELOG.md`
### Workflow Workspace Setup
### Consuming @uncaged/* Packages
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
```
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
### End-to-end: Monorepo → Registry → Workspace → Bundle
The recommended development flow for building workflows:
```
workflow/ (monorepo) — engine, runtime, templates, agents
│ bun run publish:giteaauto topo-sort, bun pm pack → npm publish
│ bun release build + test + changeset publish
git.shazhou.work npm registry — @uncaged/* scoped packages
│ bun install — via bunfig.toml scoped registry
npmjs.org — @uncaged/* scoped packages (public)
│ bun install
my-workflows/ (workspace) — bunfig.toml + normal package.json
my-workflows/ (workspace) — 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
1. **Monorepo changes**`bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
2. **Workspace**`bun install` fetches latest from npmjs
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>`
+8 -2
View File
@@ -1,7 +1,13 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"files": {
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
"includes": [
"**",
"!**/dist",
"!**/node_modules",
"!packages/workflow/workflow",
"!xiaoju/scripts/bundle.ts"
]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": {
+16
View File
@@ -0,0 +1,16 @@
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
import {
buildDevelopDescriptor,
developWorkflowDefinition,
} from "./packages/workflow-template-develop/src/index.js";
const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
workspace: null,
});
export const descriptor = buildDevelopDescriptor();
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
+9 -6
View File
@@ -4,20 +4,23 @@
"workspaces": [
"packages/*"
],
"overrides": {
"@uncaged/json-cas": "^0.1.0",
"@uncaged/json-cas-workflow": "^0.1.0"
},
"scripts": {
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check .",
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
"typecheck": "bunx tsc --build",
"format": "biome format --write .",
"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"
"changeset": "bunx changeset",
"version": "bunx changeset version",
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@changesets/cli": "^2.31.0",
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"bun-types": "^1.3.13"
+138
View File
@@ -0,0 +1,138 @@
# @uncaged/cli-workflow
## 0.5.0-alpha.4
### Patch Changes
- Updated dependencies
- Updated dependencies [f74b482]
- Updated dependencies [f74b482]
- @uncaged/workflow-util@0.5.0-alpha.4
- @uncaged/workflow-protocol@0.5.0-alpha.4
- @uncaged/workflow-cas@0.5.0-alpha.4
- @uncaged/workflow-execute@0.5.0-alpha.4
- @uncaged/workflow-gateway@0.5.0-alpha.4
- @uncaged/workflow-register@0.5.0-alpha.4
- @uncaged/workflow-runtime@0.5.0-alpha.4
## 0.5.0-alpha.3
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.3
- @uncaged/workflow-cas@0.5.0-alpha.3
- @uncaged/workflow-execute@0.5.0-alpha.3
- @uncaged/workflow-gateway@0.5.0-alpha.3
- @uncaged/workflow-register@0.5.0-alpha.3
- @uncaged/workflow-runtime@0.5.0-alpha.3
- @uncaged/workflow-util@0.5.0-alpha.3
## 0.5.0-alpha.2
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.2
- @uncaged/workflow-cas@0.5.0-alpha.2
- @uncaged/workflow-execute@0.5.0-alpha.2
- @uncaged/workflow-gateway@0.5.0-alpha.2
- @uncaged/workflow-register@0.5.0-alpha.2
- @uncaged/workflow-runtime@0.5.0-alpha.2
- @uncaged/workflow-util@0.5.0-alpha.2
## 0.5.0-alpha.1
### Patch Changes
- @uncaged/workflow-cas@0.5.0-alpha.1
- @uncaged/workflow-execute@0.5.0-alpha.1
- @uncaged/workflow-gateway@0.5.0-alpha.1
- @uncaged/workflow-protocol@0.5.0-alpha.1
- @uncaged/workflow-register@0.5.0-alpha.1
- @uncaged/workflow-runtime@0.5.0-alpha.1
- @uncaged/workflow-util@0.5.0-alpha.1
## 0.5.0-alpha.0
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.0
- @uncaged/workflow-cas@0.5.0-alpha.0
- @uncaged/workflow-execute@0.5.0-alpha.0
- @uncaged/workflow-register@0.5.0-alpha.0
- @uncaged/workflow-runtime@0.5.0-alpha.0
- @uncaged/workflow-util@0.5.0-alpha.0
- @uncaged/workflow-gateway@0.5.0-alpha.0
## 0.4.5
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.5
- @uncaged/workflow-cas@0.4.5
- @uncaged/workflow-execute@0.4.5
- @uncaged/workflow-gateway@0.4.5
- @uncaged/workflow-register@0.4.5
- @uncaged/workflow-runtime@0.4.5
- @uncaged/workflow-util@0.4.5
## 0.4.4
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.4
- @uncaged/workflow-cas@0.4.4
- @uncaged/workflow-execute@0.4.4
- @uncaged/workflow-gateway@0.4.4
- @uncaged/workflow-register@0.4.4
- @uncaged/workflow-runtime@0.4.4
- @uncaged/workflow-util@0.4.4
## 0.4.3
### Patch Changes
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
- Updated dependencies
- @uncaged/workflow-cas@0.4.3
- @uncaged/workflow-execute@0.4.3
- @uncaged/workflow-gateway@0.4.3
- @uncaged/workflow-protocol@0.4.3
- @uncaged/workflow-register@0.4.3
- @uncaged/workflow-runtime@0.4.3
- @uncaged/workflow-util@0.4.3
## 0.4.2
### Patch Changes
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
- Updated dependencies
- @uncaged/workflow-cas@0.4.2
- @uncaged/workflow-execute@0.4.2
- @uncaged/workflow-gateway@0.4.2
- @uncaged/workflow-protocol@0.4.2
- @uncaged/workflow-register@0.4.2
- @uncaged/workflow-runtime@0.4.2
- @uncaged/workflow-util@0.4.2
## 0.4.0
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-cas@0.4.0
- @uncaged/workflow-execute@0.4.0
- @uncaged/workflow-gateway@0.4.0
- @uncaged/workflow-protocol@0.4.0
- @uncaged/workflow-register@0.4.0
- @uncaged/workflow-runtime@0.4.0
- @uncaged/workflow-util@0.4.0
@@ -20,9 +20,6 @@ import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
`;
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`;
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
`${fixtureDescriptor}import fs from "node:fs";
export const run = async function* (input, options) {
fs.existsSync(".");
const cas = options.cas;
const h = await putContentMerkleNode(cas, input.prompt);
const h = await cas.put(input.prompt);
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
return { returnCode: 0, summary: "done" };
}
@@ -155,10 +152,9 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
},
graph: { edges: [] },
};
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, input.prompt);
const h = await cas.put( input.prompt);
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
return { returnCode: 0, summary: "ok" };
};
@@ -197,9 +193,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -228,9 +224,9 @@ export const run = async function* (input, options) {
const dtsPath = join(bundleDir, "types.d.ts");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -261,9 +257,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -284,16 +280,16 @@ export const run = async function* (input, options) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v1");
const h = await cas.put( "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v2");
const h = await cas.put( "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
@@ -326,16 +322,16 @@ export const run = async function* (input, options) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v1");
const h = await cas.put( "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v2");
const h = await cas.put( "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
@@ -378,9 +374,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -391,9 +387,9 @@ export const run = async function* (input, options) {
expect(add1.ok).toBe(true);
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "y");
const h = await cas.put( "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
@@ -446,9 +442,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -463,9 +459,9 @@ export const run = async function* (input, options) {
const hash1 = add1.value.hash;
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "y");
const h = await cas.put( "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
import { createApp } from "../src/commands/serve/app.js";
import { createApp } from "../src/commands/connect/app.js";
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
function buildApp(storageRoot: string) {
const app = createApp(storageRoot);
const app = createApp(storageRoot, null);
return {
fetch: (path: string, init?: RequestInit) =>
app.fetch(new Request(`http://localhost${path}`, init)),
@@ -115,7 +115,7 @@ describe("serve error handling", () => {
});
test("global error handler returns 500 with JSON", async () => {
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
app.get("/test-error", () => {
throw new Error("boom");
});
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
describe("serve security", () => {
test("CORS headers present on responses", async () => {
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
const res2 = await app.fetch(
new Request("http://localhost/healthz", {
headers: { Origin: "http://localhost:5173" },
@@ -15,9 +15,7 @@ import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
export const descriptor = {
const threeRoleBundleSource = `export const descriptor = {
description: "fork-cli",
roles: {
planner: { description: "planner", schema: {} },
@@ -30,16 +28,16 @@ export const run = async function* (input, options) {
const cas = options.cas;
const has = (r) => input.steps.some((s) => s.role === r);
if (!has("planner")) {
const h = await putContentMerkleNode(cas, "p1");
const h = await cas.put( "p1");
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
}
if (!has("coder")) {
const h = await putContentMerkleNode(cas, "c1");
const h = await cas.put( "c1");
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
}
if (!has("reviewer")) {
const body = "rev-" + String(input.steps.length);
const h = await putContentMerkleNode(cas, body);
const h = await cas.put( body);
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
}
return { returnCode: 0, summary: "done" };
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
cmdJsonCasInit,
cmdNodeGet,
cmdNodeList,
cmdNodeWalk,
cmdWorkflowRegister,
cmdWorkflowShow,
formatNodeWalk,
formatWorkflowShow,
getJsonCasDir,
} from "../src/commands/json-cas/index.js";
const SIMPLE_WORKFLOW = {
name: "test-workflow",
description: "A test workflow for CLI tests",
roles: {
analyst: {
description: "Analyses the input",
systemPrompt: "You are an analyst.",
extractPrompt: "Extract the analysis.",
schema: { type: "object", properties: { result: { type: "string" } } },
},
},
moderator: [{ from: "analyst", to: "__end__", when: null }],
};
describe("json-cas CLI commands", () => {
let storageRoot: string;
beforeEach(async () => {
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-json-cas-"));
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
test("getJsonCasDir returns path under storageRoot", () => {
const dir = getJsonCasDir(storageRoot);
expect(dir).toBe(join(storageRoot, "json-cas"));
});
test("init bootstraps the store and returns a workflow type hash", async () => {
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
expect(typeof workflowTypeHash).toBe("string");
expect(workflowTypeHash.length).toBe(13);
});
test("init is idempotent", async () => {
const hash1 = await cmdJsonCasInit(storageRoot);
const hash2 = await cmdJsonCasInit(storageRoot);
expect(hash1).toBe(hash2);
});
test("workflow register returns a hash", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const result = await cmdWorkflowRegister(storageRoot, filePath);
expect(typeof result.hash).toBe("string");
expect(result.hash.length).toBe(13);
});
test("workflow register is idempotent", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const r1 = await cmdWorkflowRegister(storageRoot, filePath);
const r2 = await cmdWorkflowRegister(storageRoot, filePath);
expect(r1.hash).toBe(r2.hash);
});
test("workflow show loads a registered workflow", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const wf = await cmdWorkflowShow(storageRoot, hash);
expect(wf).not.toBeNull();
if (wf === null) return;
expect(wf.name).toBe("test-workflow");
expect(wf.description).toBe("A test workflow for CLI tests");
expect(Object.keys(wf.roles)).toContain("analyst");
expect(wf.roles.analyst.systemPrompt).toBe("You are an analyst.");
expect(wf.moderator).toHaveLength(1);
expect(wf.moderator[0].from).toBe("analyst");
});
test("workflow show returns null for unknown hash", async () => {
await cmdJsonCasInit(storageRoot);
const result = await cmdWorkflowShow(storageRoot, "AAAAAAAAAAAAA");
expect(result).toBeNull();
});
test("formatWorkflowShow produces expected output", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const wf = await cmdWorkflowShow(storageRoot, hash);
if (wf === null) throw new Error("workflow not found");
const output = formatWorkflowShow(hash, wf);
expect(output).toContain("test-workflow");
expect(output).toContain(hash);
expect(output).toContain("analyst");
expect(output).toContain("moderator:");
});
test("node list returns hashes after registration", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
await cmdWorkflowRegister(storageRoot, filePath);
const hashes = await cmdNodeList(storageRoot);
expect(hashes.length).toBeGreaterThan(0);
});
test("node list returns empty array for empty store", async () => {
const hashes = await cmdNodeList(storageRoot);
expect(hashes).toEqual([]);
});
test("node get returns JSON for a known hash", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const json = await cmdNodeGet(storageRoot, hash);
expect(json).not.toBeNull();
if (json === null) return;
const parsed = JSON.parse(json) as unknown;
expect(parsed).toMatchObject({ payload: expect.anything() });
});
test("node get returns null for unknown hash", async () => {
const result = await cmdNodeGet(storageRoot, "AAAAAAAAAAAAA");
expect(result).toBeNull();
});
test("node walk traverses the workflow DAG", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const entries = await cmdNodeWalk(storageRoot, hash);
expect(entries).not.toBeNull();
if (entries === null) return;
// should include at least the workflow node, role node, and role-schema node
expect(entries.length).toBeGreaterThanOrEqual(3);
const hashes = entries.map((e) => e.hash);
expect(hashes).toContain(hash);
});
test("node walk returns null for unknown root", async () => {
const result = await cmdNodeWalk(storageRoot, "AAAAAAAAAAAAA");
expect(result).toBeNull();
});
test("formatNodeWalk produces output with node hashes", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const entries = await cmdNodeWalk(storageRoot, hash);
if (entries === null) throw new Error("walk failed");
const output = formatNodeWalk(hash, entries);
expect(output).toContain(`walk from: ${hash}`);
expect(output).toContain(hash);
});
});
@@ -23,9 +23,6 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`;
const threadFixtureDescriptor = `export const descriptor = {
description: "thread-cli",
roles: {
@@ -41,25 +38,23 @@ const threadFixtureDescriptor = `export const descriptor = {
`;
const fastBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
let h = await cas.put( "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await putContentMerkleNode(cas, "code");
h = await cas.put( "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const slowPlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
await new Promise((r) => setTimeout(r, 400));
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
let h = await cas.put( "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await putContentMerkleNode(cas, "code");
h = await cas.put( "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
@@ -68,37 +63,34 @@ export const run = async function* (input, options) {
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
let h = await cas.put( "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
await new Promise((r) => setTimeout(r, 10000));
h = await putContentMerkleNode(cas, "code");
h = await cas.put( "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const pauseResumeBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (_input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "f");
let h = await cas.put( "f");
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
await new Promise((r) => setTimeout(r, 1500));
h = await putContentMerkleNode(cas, "s");
h = await cas.put( "s");
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (_input, options) {
await new Promise((r) => setTimeout(r, 900));
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
@@ -180,6 +172,9 @@ describe("cli thread commands", () => {
}
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 120);
const shown = await cmdThreadShow(storageRoot, threadId);
expect(shown.ok).toBe(true);
if (!shown.ok) {
+15 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.3.21",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
@@ -11,17 +11,24 @@
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-gateway": "workspace:*",
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-execute": "workspace:*",
"@uncaged/workflow-register": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/json-cas": "^0.1.1",
"@uncaged/json-cas-fs": "^0.1.1",
"@uncaged/json-cas-workflow": "^0.1.1",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-execute": "workspace:^",
"@uncaged/workflow-gateway": "workspace:^",
"@uncaged/workflow-json-def": "workspace:*",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"hono": "^4.12.18",
"yaml": "^2.8.4"
},
"scripts": {
"test": "bun test"
},
"publishConfig": {
"access": "public"
}
}
+5 -2
View File
@@ -3,8 +3,9 @@ import { printCliError, printCliLine } from "./cli-output.js";
import { getCommandRegistry } from "./cli-registry.js";
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher } from "./commands/cas/index.js";
import { dispatchConnect } from "./commands/connect/index.js";
import { createInitDispatcher } from "./commands/init/index.js";
import { dispatchServe } from "./commands/serve/index.js";
import { createJsonCasDispatcher } from "./commands/json-cas/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";
@@ -43,6 +44,7 @@ const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
const dispatchThread = createThreadDispatcher({ dispatchGroup });
const dispatchCas = createCasDispatcher({ dispatchGroup });
const dispatchInit = createInitDispatcher({ dispatchGroup });
const dispatchJsonCas = createJsonCasDispatcher({ dispatchGroup });
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
if (topic === undefined) {
@@ -71,7 +73,8 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
skill: dispatchSkill,
run: dispatchRun,
live: dispatchLive,
serve: dispatchServe,
connect: dispatchConnect,
"json-cas": dispatchJsonCas,
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
@@ -2,6 +2,7 @@ import type { CommandGroup } from "./cli-command-types.js";
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
import { JSON_CAS_SUBCOMMAND_TABLE } from "./commands/json-cas/index.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
@@ -52,6 +53,14 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
name: "setup",
commands: [...SETUP_USAGE_COMMANDS],
},
{
name: "json-cas",
commands: Object.entries(JSON_CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
];
}
+4 -3
View File
@@ -13,6 +13,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
cas: "Content-addressable storage:",
init: "Development:",
setup: "Configuration:",
"json-cas": "JSON-CAS engine:",
};
export function formatUsageCommandLines(
@@ -59,12 +60,12 @@ export function formatCliUsage(
);
lines.push("");
lines.push("Server:");
lines.push("Gateway:");
lines.push(
...formatUsageCommandLines([
{
prefix: "serve [--port N] [--host ADDR]",
description: "Start HTTP API server (default: 127.0.0.1:7860)",
prefix: "connect [--name NAME] [--gateway URL]",
description: "Connect to workflow gateway via WebSocket",
},
]),
);
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
const MAX_BODY_SIZE = 1_048_576; // 1 MB
export function createApp(storageRoot: string, agentToken: string | null): Hono {
export function createApp(storageRoot: string, clientToken: string | null): Hono {
const app = new Hono();
app.onError((_err, c) => {
@@ -37,11 +37,11 @@ export function createApp(storageRoot: string, agentToken: string | null): Hono
await next();
});
// ── Agent token auth (skip healthz) ───────────────────────────────
if (agentToken !== null) {
// ── Client token auth (skip healthz) ───────────────────────────────
if (clientToken !== null) {
app.use("/api/*", async (c, next) => {
const token = c.req.header("X-Agent-Token");
if (token !== agentToken) {
const token = c.req.header("X-Client-Token");
if (token !== clientToken) {
return c.json({ error: "unauthorized" }, 401);
}
await next();
@@ -0,0 +1,111 @@
import { randomUUID } from "node:crypto";
import { hostname as osHostname } from "node:os";
import { ok, type Result } from "@uncaged/workflow-protocol";
import { createLogger } from "@uncaged/workflow-util";
import { printCliLine } from "../../cli-output.js";
import { createApp } from "./app.js";
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
import type { ConnectOptions } 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;
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
const next = argv[i + 1];
if (next === undefined) {
return { ok: false, error: `${flag} requires a value` };
}
return ok(next);
}
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
let name = osHostname().split(".")[0].toLowerCase();
let gatewayUrl = DEFAULT_GATEWAY_URL;
const gatewaySecret = process.env.WORKFLOW_DASHBOARD_SECRET ?? "";
const stringFlags: Record<string, (v: string) => void> = {
"--name": (v) => {
name = v;
},
"--gateway": (v) => {
gatewayUrl = v;
},
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg in stringFlags) {
const r = requireNextArg(argv, i, arg);
if (!r.ok) return r;
stringFlags[arg](r.value);
i++;
}
}
return ok({ name, gatewayUrl, gatewaySecret });
}
export async function dispatchConnect(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseConnectArgv(argv);
if (!parsed.ok) {
printCliLine(`error: ${parsed.error}`);
return 1;
}
const options = parsed.value;
if (options.gatewaySecret === "") {
printCliLine("error: WORKFLOW_DASHBOARD_SECRET is required");
return 1;
}
const clientToken = randomUUID();
const app = createApp(storageRoot, clientToken);
const log = createLogger({ sink: { kind: "stderr" } });
const stopWsClient = startGatewayWsClient({
gatewayUrl: options.gatewayUrl,
name: options.name,
secret: options.gatewaySecret,
appFetch: app.fetch,
log,
});
printCliLine("connected to gateway via WebSocket");
// Register with gateway for discovery
const registered = await registerWithGateway(
options.gatewayUrl,
options.name,
`ws://${options.name}`,
options.gatewaySecret,
clientToken,
);
if (registered) {
printCliLine(`registered with gateway as "${options.name}"`);
}
const heartbeatTimer = startHeartbeat(
options.gatewayUrl,
options.name,
`ws://${options.name}`,
options.gatewaySecret,
clientToken,
HEARTBEAT_INTERVAL_MS,
);
const cleanup = async () => {
clearInterval(heartbeatTimer);
stopWsClient();
printCliLine("unregistering from gateway...");
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
await new Promise(() => {});
return 0;
}
@@ -1,51 +1,17 @@
import { printCliLine } from "../../cli-output.js";
type TunnelHandle = {
process: ReturnType<typeof Bun.spawn>;
url: string;
};
export async function startTunnel(port: number): Promise<TunnelHandle | null> {
const proc = Bun.spawn(["cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
stdout: "pipe",
stderr: "pipe",
});
// cloudflared prints the URL to stderr
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
let buffer = "";
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
if (match) {
// Release the reader so stderr keeps flowing without backpressure
reader.releaseLock();
return { process: proc, url: match[0] };
}
}
reader.releaseLock();
proc.kill();
return null;
}
export async function registerWithGateway(
gatewayUrl: string,
name: string,
tunnelUrl: string,
localUrl: string,
secret: string,
agentToken: string,
clientToken: string,
): Promise<boolean> {
try {
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
});
if (!resp.ok) {
const body = await resp.text();
@@ -77,12 +43,12 @@ export async function unregisterFromGateway(
export function startHeartbeat(
gatewayUrl: string,
name: string,
tunnelUrl: string,
localUrl: string,
secret: string,
agentToken: string,
clientToken: string,
intervalMs: number,
): ReturnType<typeof setInterval> {
return setInterval(() => {
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
}, intervalMs);
}
@@ -0,0 +1,2 @@
export { dispatchConnect } from "./connect.js";
export type { ConnectOptions } from "./types.js";
@@ -0,0 +1,5 @@
export type ConnectOptions = {
name: string;
gatewayUrl: string;
gatewaySecret: string;
};
@@ -5,7 +5,7 @@ export type GatewayWsClientParams = {
gatewayUrl: string;
name: string;
secret: string;
localPort: number;
appFetch: (request: Request) => Response | Promise<Response>;
log: LogFn;
};
@@ -44,20 +44,19 @@ async function handleGatewayMessage(
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);
}
const localUrl = `http://localhost${req.path}`;
const headers = new Headers(req.headers);
let resp: Response;
try {
resp = await fetch(localUrl, {
method: req.method,
headers: initHeaders,
body: req.body === null ? undefined : req.body,
});
resp = await params.appFetch(
new Request(localUrl, {
method: req.method,
headers,
body: req.body === null ? undefined : req.body,
}),
);
} catch (e) {
params.log("R4N7BQ3C", `local proxy fetch failed: ${String(e)}`);
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
const errBody: WsResponse = {
id: req.id,
status: 502,
@@ -100,7 +99,7 @@ export function startGatewayWsClient(params: GatewayWsClientParams): () => void
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})`);
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
reconnectTimer = setTimeout(connect, delayMs);
};
@@ -143,7 +142,7 @@ export function startGatewayWsClient(params: GatewayWsClientParams): () => void
ws.addEventListener("message", (ev) => {
const data = ev.data;
if (typeof data !== "string") {
params.log("T9W2KL5H", "gateway WebSocket non-text frame ignored");
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
return;
}
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
@@ -51,7 +51,6 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
description: "Says hello — replace with your first role.",
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
schema: greeterMetaSchema,
extractRefs: null,
};
`;
}
@@ -196,18 +196,13 @@ uncaged-workflow init workspace ${workspaceName}
function bundleTs(): string {
return [
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
'import { mkdir, readdir, 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");',
"}",
@@ -216,36 +211,6 @@ function bundleTs(): 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[];",
@@ -261,7 +226,6 @@ function bundleTs(): string {
' 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);",
@@ -272,7 +236,6 @@ function bundleTs(): string {
' target: "node",',
" splitting: false,",
' naming: { entry: "[name].esm.js" },',
" external,",
" });",
" if (!result.success) {",
" for (const log of result.logs) {",
@@ -0,0 +1,227 @@
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { cmdJsonCasInit } from "./init.js";
import { cmdNodeGet } from "./node-get.js";
import { cmdNodeList } from "./node-list.js";
import { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
import { getJsonCasDir } from "./store.js";
import { cmdThreadShow, formatThreadShow } from "./thread-show.js";
import type { JsonCasDispatchDeps } from "./types.js";
import { cmdWorkflowRegister } from "./workflow-register.js";
import { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
// ── node subcommands ─────────────────────────────────────────────────────────
export async function dispatchNodeGet(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas node get requires <hash>");
return 1;
}
const result = await cmdNodeGet(storageRoot, hash);
if (result === null) {
printCliError(`error: node not found: ${hash}`);
return 1;
}
printCliLine(result);
return 0;
}
export async function dispatchNodeList(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError("error: json-cas node list takes no arguments");
return 1;
}
const hashes = await cmdNodeList(storageRoot);
if (hashes.length === 0) {
printCliLine("(no nodes)");
return 0;
}
for (const hash of hashes) {
printCliLine(hash);
}
return 0;
}
export async function dispatchNodeWalk(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas node walk requires <hash>");
return 1;
}
const entries = await cmdNodeWalk(storageRoot, hash);
if (entries === null) {
printCliError(`error: node not found: ${hash}`);
return 1;
}
printCliLine(formatNodeWalk(hash, entries));
return 0;
}
export const JSON_CAS_NODE_TABLE: Record<string, CommandEntry> = {
get: { handler: dispatchNodeGet, args: "<hash>", description: "Get a CAS node as JSON" },
list: { handler: dispatchNodeList, args: "", description: "List all hashes in the store" },
walk: {
handler: dispatchNodeWalk,
args: "<hash>",
description: "Walk the DAG from a node, show referenced nodes",
},
};
// ── workflow subcommands ─────────────────────────────────────────────────────
export async function dispatchWorkflowRegister(
storageRoot: string,
argv: string[],
): Promise<number> {
const file = argv[0];
if (file === undefined || argv.length > 1) {
printCliError("error: json-cas workflow register requires <file.json>");
return 1;
}
const result = await cmdWorkflowRegister(storageRoot, file);
printCliLine(`registered workflow: ${result.hash}`);
return 0;
}
export async function dispatchWorkflowShow(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas workflow show requires <hash>");
return 1;
}
const wf = await cmdWorkflowShow(storageRoot, hash);
if (wf === null) {
printCliError(`error: workflow not found: ${hash}`);
return 1;
}
printCliLine(formatWorkflowShow(hash, wf));
return 0;
}
export const JSON_CAS_WORKFLOW_TABLE: Record<string, CommandEntry> = {
register: {
handler: dispatchWorkflowRegister,
args: "<file.json>",
description: "Register a workflow definition from a JSON file",
},
show: {
handler: dispatchWorkflowShow,
args: "<hash>",
description: "Show a workflow by its CAS hash",
},
};
// ── thread subcommands ───────────────────────────────────────────────────────
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas thread show requires <start-hash>");
return 1;
}
const result = await cmdThreadShow(storageRoot, hash);
if (result === null) {
printCliError(`error: thread start node not found: ${hash}`);
return 1;
}
printCliLine(formatThreadShow(result));
return 0;
}
export const JSON_CAS_THREAD_TABLE: Record<string, CommandEntry> = {
show: {
handler: dispatchThreadShow,
args: "<start-hash>",
description: "Walk and display thread steps from a thread-start hash",
},
};
// ── top-level json-cas subcommands ───────────────────────────────────────────
export async function dispatchJsonCasInit(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError("error: json-cas init takes no arguments");
return 1;
}
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
const dir = getJsonCasDir(storageRoot);
printCliLine(`initialized json-cas store at ${dir}`);
printCliLine(`workflow type hash: ${workflowTypeHash}`);
return 0;
}
function printSubgroupHelp(
groupPath: string,
table: Record<string, CommandEntry>,
sub: string | undefined,
): number {
if (sub === undefined || sub === "--help" || sub === "-h") {
const lines = [`json-cas ${groupPath} subcommands:\n`];
for (const [name, e] of Object.entries(table)) {
const args = e.args ? ` ${e.args}` : "";
lines.push(` uncaged-workflow json-cas ${groupPath} ${name}${args}`);
lines.push(` ${e.description}\n`);
}
printCliLine(lines.join("\n"));
return sub === undefined ? 1 : 0;
}
return -1;
}
async function dispatchSubgroup(
groupPath: string,
table: Record<string, CommandEntry>,
storageRoot: string,
argv: string[],
): Promise<number> {
const sub = argv[0];
const helpResult = printSubgroupHelp(groupPath, table, sub);
if (helpResult >= 0) return helpResult;
const entry = table[sub as string];
if (entry === undefined) {
printCliError(`error: unknown json-cas ${groupPath} subcommand: ${sub}`);
return 1;
}
return entry.handler(storageRoot, argv.slice(1));
}
export const JSON_CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
init: {
handler: dispatchJsonCasInit,
args: "",
description: "Initialize json-cas store and register workflow schemas",
},
workflow: {
handler: (storageRoot, argv) =>
dispatchSubgroup("workflow", JSON_CAS_WORKFLOW_TABLE, storageRoot, argv),
args: "<register|show>",
description: "Manage json-cas workflow definitions",
},
thread: {
handler: (storageRoot, argv) =>
dispatchSubgroup("thread", JSON_CAS_THREAD_TABLE, storageRoot, argv),
args: "<show>",
description: "Inspect json-cas thread execution records",
},
node: {
handler: (storageRoot, argv) =>
dispatchSubgroup("node", JSON_CAS_NODE_TABLE, storageRoot, argv),
args: "<get|list|walk>",
description: "Low-level access to json-cas store nodes",
},
};
export function createJsonCasDispatcher(deps: JsonCasDispatchDeps) {
const { dispatchGroup } = deps;
return async function dispatchJsonCas(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("json-cas", JSON_CAS_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`error: unknown json-cas subcommand: ${sub}`);
return 1;
};
}
@@ -0,0 +1,25 @@
export {
createJsonCasDispatcher,
dispatchJsonCasInit,
dispatchNodeGet,
dispatchNodeList,
dispatchNodeWalk,
dispatchThreadShow,
dispatchWorkflowRegister,
dispatchWorkflowShow,
JSON_CAS_NODE_TABLE,
JSON_CAS_SUBCOMMAND_TABLE,
JSON_CAS_THREAD_TABLE,
JSON_CAS_WORKFLOW_TABLE,
} from "./dispatch.js";
export { cmdJsonCasInit } from "./init.js";
export { cmdNodeGet } from "./node-get.js";
export { cmdNodeList } from "./node-list.js";
export { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
export { getJsonCasDir, openStore } from "./store.js";
export type { ThreadShowResult, ThreadStep } from "./thread-show.js";
export { cmdThreadShow, formatThreadShow } from "./thread-show.js";
export type { JsonCasDispatchDeps } from "./types.js";
export type { WorkflowRegisterResult } from "./workflow-register.js";
export { cmdWorkflowRegister } from "./workflow-register.js";
export { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
@@ -0,0 +1,6 @@
import { openStore } from "./store.js";
export async function cmdJsonCasInit(storageRoot: string): Promise<string> {
const { typeHashes } = await openStore(storageRoot);
return typeHashes.workflow;
}
@@ -0,0 +1,9 @@
import { createFsStore } from "@uncaged/json-cas-fs";
import { getJsonCasDir } from "./store.js";
export async function cmdNodeGet(storageRoot: string, hash: string): Promise<string | null> {
const store = createFsStore(getJsonCasDir(storageRoot));
const node = store.get(hash);
if (node === null) return null;
return JSON.stringify(node, null, 2);
}
@@ -0,0 +1,7 @@
import { createFsStore } from "@uncaged/json-cas-fs";
import { getJsonCasDir } from "./store.js";
export async function cmdNodeList(storageRoot: string): Promise<string[]> {
const store = createFsStore(getJsonCasDir(storageRoot));
return store.list();
}
@@ -0,0 +1,50 @@
import type { CasNode, Hash } from "@uncaged/json-cas";
import { walk } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { getJsonCasDir } from "./store.js";
export type WalkEntry = {
hash: Hash;
type: Hash;
timestamp: number;
payloadPreview: string;
};
export async function cmdNodeWalk(
storageRoot: string,
rootHash: string,
): Promise<WalkEntry[] | null> {
const store = createFsStore(getJsonCasDir(storageRoot));
if (!store.has(rootHash)) return null;
const entries: WalkEntry[] = [];
walk(store, rootHash, (hash: Hash, node: CasNode) => {
const preview = JSON.stringify(node.payload).slice(0, 100);
entries.push({
hash,
type: node.type,
timestamp: node.timestamp,
payloadPreview: preview,
});
});
return entries;
}
export function formatNodeWalk(rootHash: string, entries: WalkEntry[]): string {
const lines: string[] = [];
lines.push(`walk from: ${rootHash}`);
lines.push(`nodes: ${entries.length}`);
for (const entry of entries) {
lines.push("");
lines.push(` hash: ${entry.hash}`);
lines.push(` type: ${entry.type}`);
lines.push(` time: ${new Date(entry.timestamp).toISOString()}`);
lines.push(` data: ${entry.payloadPreview}`);
}
return lines.join("\n");
}
@@ -0,0 +1,23 @@
import { join } from "node:path";
import type { Store } from "@uncaged/json-cas";
import { bootstrap } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
export function getJsonCasDir(storageRoot: string): string {
return join(storageRoot, "json-cas");
}
export type OpenStoreResult = {
store: Store;
typeHashes: WorkflowSchemaHashes;
};
export async function openStore(storageRoot: string): Promise<OpenStoreResult> {
const dir = getJsonCasDir(storageRoot);
const store = createFsStore(dir);
await bootstrap(store);
const typeHashes = await registerWorkflowSchemas(store);
return { store, typeHashes };
}
@@ -0,0 +1,116 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
ContentPayload,
ThreadStepPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import { openStore } from "./store.js";
type StepEntry = {
hash: Hash;
payload: ThreadStepPayload;
};
function collectStepsInOrder(
store: Store,
typeHashes: WorkflowSchemaHashes,
startHash: Hash,
): StepEntry[] {
const stepMap = new Map<Hash, StepEntry>();
for (const hash of store.list()) {
const node = store.get(hash);
if (node === null || node.type !== typeHashes.threadStep) continue;
const p = node.payload as ThreadStepPayload;
if (p.start === startHash) {
stepMap.set(hash, { hash, payload: p });
}
}
if (stepMap.size === 0) return [];
// Find the terminal step: the one whose hash is not referenced as `previous` by any other step
const previousSet = new Set<Hash>();
for (const entry of stepMap.values()) {
if (entry.payload.previous !== null) {
previousSet.add(entry.payload.previous);
}
}
const terminalEntries = [...stepMap.values()].filter((e) => !previousSet.has(e.hash));
if (terminalEntries.length === 0) return [...stepMap.values()];
// Walk backward from terminal to build ordered list
const ordered: StepEntry[] = [];
let current: StepEntry | undefined = terminalEntries[0];
while (current !== undefined) {
ordered.unshift(current);
const prevHash = current.payload.previous;
if (prevHash === null) break;
current = stepMap.get(prevHash);
}
return ordered;
}
function getContentText(store: Store, contentHash: Hash): string {
const node = store.get(contentHash);
if (node === null) return "(not found)";
const payload = node.payload as ContentPayload;
return typeof payload.text === "string" ? payload.text : "(no text)";
}
export type ThreadStep = {
hash: Hash;
role: string;
meta: Record<string, unknown>;
contentPreview: string;
};
export type ThreadShowResult = {
startHash: Hash;
steps: ThreadStep[];
};
export async function cmdThreadShow(
storageRoot: string,
startHash: string,
): Promise<ThreadShowResult | null> {
const { store, typeHashes } = await openStore(storageRoot);
const startNode = store.get(startHash);
if (startNode === null) return null;
const entries = collectStepsInOrder(store, typeHashes, startHash);
const steps: ThreadStep[] = entries.map((entry) => {
const rawText = getContentText(store, entry.payload.content);
const preview = rawText.slice(0, 120).replace(/\n/g, " ");
return {
hash: entry.hash,
role: entry.payload.role,
meta: entry.payload.meta,
contentPreview: preview,
};
});
return { startHash, steps };
}
export function formatThreadShow(result: ThreadShowResult): string {
const lines: string[] = [];
lines.push(`thread: ${result.startHash}`);
lines.push(`steps: ${result.steps.length}`);
for (let i = 0; i < result.steps.length; i++) {
const step = result.steps[i];
lines.push("");
lines.push(` [${i + 1}] ${step.role} (${step.hash})`);
if (Object.keys(step.meta).length > 0) {
lines.push(` meta: ${JSON.stringify(step.meta)}`);
}
lines.push(` > ${step.contentPreview}`);
}
return lines.join("\n");
}
@@ -0,0 +1,5 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type JsonCasDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import type { WorkflowInput } from "@uncaged/workflow-json-def";
import { registerWorkflow } from "@uncaged/workflow-json-def";
import { openStore } from "./store.js";
export type WorkflowRegisterResult = {
hash: string;
};
export async function cmdWorkflowRegister(
storageRoot: string,
filePath: string,
): Promise<WorkflowRegisterResult> {
const raw = readFileSync(filePath, "utf-8");
const workflowDef = JSON.parse(raw) as WorkflowInput;
const { store, typeHashes } = await openStore(storageRoot);
const hash = await registerWorkflow(store, typeHashes, workflowDef);
return { hash };
}
@@ -0,0 +1,38 @@
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
import { loadWorkflow } from "@uncaged/workflow-json-def";
import { openStore } from "./store.js";
export async function cmdWorkflowShow(
storageRoot: string,
hash: string,
): Promise<HydratedWorkflow | null> {
const { store, typeHashes } = await openStore(storageRoot);
return loadWorkflow(store, typeHashes, hash);
}
export function formatWorkflowShow(hash: string, wf: HydratedWorkflow): string {
const lines: string[] = [];
lines.push(`workflow: ${wf.name}`);
lines.push(`hash: ${hash}`);
lines.push(`desc: ${wf.description}`);
lines.push("");
lines.push("roles:");
for (const [name, role] of Object.entries(wf.roles)) {
lines.push(` ${name}:`);
lines.push(` description: ${role.description}`);
lines.push(` systemPrompt: ${role.systemPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
lines.push(` extractPrompt: ${role.extractPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
}
if (wf.moderator.length > 0) {
lines.push("");
lines.push("moderator:");
for (const rule of wf.moderator) {
const when = rule.when === null ? "(always)" : `when: ${rule.when}`;
lines.push(` ${rule.from}${rule.to} [${when}]`);
}
}
return lines.join("\n");
}
@@ -1,3 +0,0 @@
export { createApp } from "./app.js";
export { dispatchServe, startServer } from "./serve.js";
export type { ServeOptions } from "./types.js";
@@ -1,180 +0,0 @@
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, 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;
export function startServer(
storageRoot: string,
options: ServeOptions,
agentToken: string | null,
): void {
const app = createApp(storageRoot, agentToken);
const server = serve({
fetch: app.fetch,
port: options.port,
hostname: options.hostname,
});
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
}
function parsePortValue(value: string | undefined): Result<number, string> {
if (value === undefined) {
return err("--port requires a value");
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
return err(`invalid port: ${value}`);
}
return ok(parsed);
}
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
const next = argv[i + 1];
if (next === undefined) {
return err(`${flag} requires a value`);
}
return ok(next);
}
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
let port = 7860;
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> = {
"--host": (v) => {
hostname = v;
},
"--name": (v) => {
name = v;
},
"--gateway": (v) => {
gatewayUrl = v;
},
"--tunnel-url": (v) => {
tunnelUrl = v;
},
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--port" || arg === "-p") {
const portResult = parsePortValue(argv[i + 1]);
if (!portResult.ok) return portResult;
port = portResult.value;
i++;
} else if (arg === "--no-tunnel") {
noTunnel = true;
} else if (arg in stringFlags) {
const r = requireNextArg(argv, i, arg);
if (!r.ok) return r;
stringFlags[arg](r.value);
i++;
}
}
return ok({ port, hostname, name, noTunnel, tunnelUrl, gatewayUrl, gatewaySecret });
}
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseServeArgv(argv);
if (!parsed.ok) {
printCliLine(`error: ${parsed.error}`);
return 1;
}
const options = parsed.value;
const agentToken = options.noTunnel ? null : randomUUID();
startServer(storageRoot, options, agentToken);
if (options.noTunnel) {
printCliLine("tunnel disabled (--no-tunnel)");
await new Promise(() => {});
return 0;
}
let resolvedTunnelUrl: string;
let stopWsClient: (() => void) | null = null;
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)");
}
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,
resolvedTunnelUrl,
options.gatewaySecret,
token,
);
if (registered) {
printCliLine(`registered with gateway as "${options.name}"`);
}
const heartbeatTimer = startHeartbeat(
options.gatewayUrl,
options.name,
resolvedTunnelUrl,
options.gatewaySecret,
token,
HEARTBEAT_INTERVAL_MS,
);
const cleanup = async () => {
clearInterval(heartbeatTimer);
stopWsClient?.();
printCliLine("unregistering from gateway...");
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
} else {
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
}
await new Promise(() => {});
return 0;
}
@@ -1,9 +0,0 @@
export type ServeOptions = {
port: number;
hostname: string;
name: string;
noTunnel: boolean;
tunnelUrl: string | null;
gatewayUrl: string;
gatewaySecret: string;
};
@@ -18,13 +18,13 @@ export async function cmdThreadRemove(
return err(`thread not found: ${threadId}`);
}
if (resolved.source === "active") {
await removeThreadEntry(resolved.bundleDir, threadId);
} else {
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
if (!hist.ok) {
return hist;
}
// Always clear both stores: between resolve and delete the worker may finish and
// move the thread from threads.json into history; branching only on resolved.source
// would skip history removal and leave a dangling row.
await removeThreadEntry(resolved.bundleDir, threadId);
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
if (!hist.ok) {
return hist;
}
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
@@ -110,7 +110,7 @@ export async function cmdAdd(
return validated;
}
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
const extracted = await extractBundleExports(resolvedPath);
if (!extracted.ok) {
return extracted;
}
+28 -6
View File
@@ -86,11 +86,11 @@ ${commandSections.join("\n\n")}
| \`run\` | \`thread run\` | Shortcut to start a thread |
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
### serve
### connect
| Command | Args | Description |
|---------|------|-------------|
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
| \`connect\` | \`[--name NAME] [--gateway URL]\` | Connect to workflow gateway via WebSocket. \`--name\` registers with the gateway. |
## Typical Workflow
@@ -249,8 +249,7 @@ Each role has:
|-------|------|---------|
| \`description\` | string | What the role does |
| \`systemPrompt\` | string | System prompt for the agent |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
## Development Workflow
@@ -301,13 +300,36 @@ function createLazyAdapter(): AdapterFn {
}
\`\`\`
### Agent CLI paths: use env() with absolute path defaults
Every env var in a bundle must have a sensible default — bundles must run without any env vars set. Use \`env(name, fallback)\` from \`@uncaged/workflow-util\`.
Discover the correct CLI path yourself (e.g. \`which cursor-agent\`, \`which hermes\`) and hardcode it as the fallback:
\`\`\`typescript
import { env } from "@uncaged/workflow-util";
// ❌ WRONG — requireEnv and optionalEnv no longer exist
const adapter = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set it"),
...
});
// ✅ CORRECT — env var is an override, fallback is the discovered absolute path
const adapter = createCursorAgent({
command: env("WORKFLOW_CURSOR_COMMAND", "/home/you/.local/bin/cursor-agent"),
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "300000")),
...
});
\`\`\`
### 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.
All other dependencies — including \`@uncaged/workflow-*\` packages, zod, and any third-party code — must be bundled into the \`.esm.js\` file. Bundles are fully self-contained: same Node/Bun version = same behavior.
### No default exports
+119
View File
@@ -0,0 +1,119 @@
# @uncaged/workflow-agent-cursor
## 0.5.0-alpha.4
### Patch Changes
- Updated dependencies
- Updated dependencies [f74b482]
- Updated dependencies [f74b482]
- @uncaged/workflow-util@0.5.0-alpha.4
- @uncaged/workflow-protocol@0.5.0-alpha.4
- @uncaged/workflow-cas@0.5.0-alpha.4
- @uncaged/workflow-runtime@0.5.0-alpha.4
- @uncaged/workflow-util-agent@0.5.0-alpha.4
## 0.5.0-alpha.3
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.3
- @uncaged/workflow-cas@0.5.0-alpha.3
- @uncaged/workflow-runtime@0.5.0-alpha.3
- @uncaged/workflow-util@0.5.0-alpha.3
- @uncaged/workflow-util-agent@0.5.0-alpha.3
## 0.5.0-alpha.2
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.2
- @uncaged/workflow-cas@0.5.0-alpha.2
- @uncaged/workflow-runtime@0.5.0-alpha.2
- @uncaged/workflow-util@0.5.0-alpha.2
- @uncaged/workflow-util-agent@0.5.0-alpha.2
## 0.5.0-alpha.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util-agent@0.5.0-alpha.1
- @uncaged/workflow-cas@0.5.0-alpha.1
- @uncaged/workflow-protocol@0.5.0-alpha.1
- @uncaged/workflow-runtime@0.5.0-alpha.1
- @uncaged/workflow-util@0.5.0-alpha.1
## 0.5.0-alpha.0
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.0
- @uncaged/workflow-cas@0.5.0-alpha.0
- @uncaged/workflow-runtime@0.5.0-alpha.0
- @uncaged/workflow-util@0.5.0-alpha.0
- @uncaged/workflow-util-agent@0.5.0-alpha.0
## 0.4.5
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.5
- @uncaged/workflow-reactor@0.4.5
- @uncaged/workflow-runtime@0.4.5
- @uncaged/workflow-util@0.4.5
- @uncaged/workflow-util-agent@0.4.5
## 0.4.4
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.4
- @uncaged/workflow-reactor@0.4.4
- @uncaged/workflow-runtime@0.4.4
- @uncaged/workflow-util@0.4.4
- @uncaged/workflow-util-agent@0.4.4
## 0.4.3
### Patch Changes
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
- Updated dependencies
- @uncaged/workflow-protocol@0.4.3
- @uncaged/workflow-reactor@0.4.3
- @uncaged/workflow-runtime@0.4.3
- @uncaged/workflow-util-agent@0.4.3
- @uncaged/workflow-util@0.4.3
## 0.4.2
### Patch Changes
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
- Updated dependencies
- @uncaged/workflow-protocol@0.4.2
- @uncaged/workflow-reactor@0.4.2
- @uncaged/workflow-runtime@0.4.2
- @uncaged/workflow-util-agent@0.4.2
- @uncaged/workflow-util@0.4.2
## 0.4.0
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.0
- @uncaged/workflow-reactor@0.4.0
- @uncaged/workflow-runtime@0.4.0
- @uncaged/workflow-util-agent@0.4.0
- @uncaged/workflow-util@0.4.0
@@ -1,36 +1,25 @@
import { describe, expect, test } from "bun:test";
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",
llmProvider: null,
});
expect(r.ok).toBe(true);
});
const baseConfig = {
command: "/usr/local/bin/cursor-agent",
model: null as string | null,
timeout: 0,
workspace: null as string | null,
};
test("accepts valid config with null workspace and llmProvider", () => {
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
...baseConfig,
});
expect(r.ok).toBe(true);
});
test("rejects non-absolute command", () => {
const r = validateCursorAgentConfig({
...baseConfig,
command: "cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
@@ -38,87 +27,38 @@ describe("validateCursorAgentConfig", () => {
}
});
test("rejects empty workspace string", () => {
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "",
llmProvider: null,
...baseConfig,
timeout: -1,
});
expect(r.ok).toBe(false);
});
test("rejects non-absolute workspace when set", () => {
const r = validateCursorAgentConfig({
...baseConfig,
workspace: "relative/path",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("workspace");
}
});
test("rejects null workspace without llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("llmProvider");
}
});
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
});
});
describe("createCursorAgent", () => {
test("returns an AdapterFn with explicit workspace", () => {
test("returns an AdapterFn", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
test("returns an AdapterFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
...baseConfig,
});
expect(typeof agent).toBe("function");
});
test("defers validation to call time (invalid config does not throw at construction)", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
...baseConfig,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
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");
});
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-cursor");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command' and 'timeout'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
expect(required).toContain("timeout");
});
test("configSchema properties include command, model, timeout, workspace", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
expect(props).toHaveProperty("workspace");
});
});
+12 -8
View File
@@ -1,28 +1,32 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.3.21",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"zod": "^4.0.0"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
@@ -1,5 +1,5 @@
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type { LogFn } from "@uncaged/workflow-util";
import * as z from "zod/v4";
@@ -7,10 +7,7 @@ const workspaceSchema = z.object({
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
});
const EXTRACT_SYSTEM_FN = (_toolName: string) =>
`You are a workspace-path extractor. Given a workflow agent context (task description and previous step outputs), identify the absolute filesystem path of the project workspace where code changes should be made. Call the tool with the absolute path.`;
function buildExtractionInput(ctx: AgentContext): string {
function buildExtractionInput(ctx: ThreadContext): string {
const lines: string[] = [];
lines.push("## Task");
lines.push(ctx.start.content);
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
}
lines.push("");
lines.push(
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
);
return lines.join("\n");
}
export async function extractWorkspacePath(
ctx: AgentContext,
provider: LlmProvider,
ctx: ThreadContext,
runtime: WorkflowRuntime,
logger: LogFn,
): Promise<string | null> {
const reactor = createThreadReactor<null>({
llm: createLlmFn(provider),
maxRounds: 2,
staticTools: [],
structuredToolFromSchema: (schema) => {
const jsonSchema = z.toJSONSchema(schema);
return {
name: "set_workspace",
tool: {
type: "function" as const,
function: {
name: "set_workspace",
description: "Set the extracted workspace path",
parameters: jsonSchema as Record<string, unknown>,
},
},
};
},
systemPromptForStructuredTool: EXTRACT_SYSTEM_FN,
toolHandler: async () => "unknown tool",
});
const input = buildExtractionInput(ctx);
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
const result = await reactor({
thread: null,
input: buildExtractionInput(ctx),
schema: workspaceSchema,
});
const result = await runtime.extract(workspaceSchema, contentHash);
const workspace = result.meta.workspace.trim();
if (!result.ok) {
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
return null;
}
const workspace = result.value.workspace.trim();
if (!workspace.startsWith("/")) {
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
return null;
+41 -33
View File
@@ -1,8 +1,8 @@
import type { AdapterFn } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger, type LogFn } from "@uncaged/workflow-util";
import {
buildThreadInput,
createTextAdapter,
createAgentAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
@@ -11,6 +11,7 @@ import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
@@ -33,36 +34,15 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model;
}
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createTextAdapter(async (ctx, prompt) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
let workspace: string;
if (config.workspace !== null) {
workspace = config.workspace;
} else {
if (config.llmProvider === null) {
throw new Error("cursor-agent: llmProvider is required when workspace is null");
}
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
if (extracted === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
);
}
workspace = extracted;
}
type CursorAgentOpt = { prompt: string; workspace: string };
function createCursorAgentFn(
config: CursorAgentConfig,
modelFlag: string,
timeoutMs: number | null,
logger: LogFn,
): AgentFn<CursorAgentOpt> {
return async (ctx, { prompt, workspace }) => {
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
@@ -86,5 +66,33 @@ export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
throwCursorSpawnError(run.error);
}
return run.value;
});
};
}
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createAgentAdapter(
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const workspace =
config.workspace !== null
? config.workspace
: await extractWorkspacePath(ctx, runtime, logger);
if (workspace === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
);
}
return { prompt, workspace };
},
);
}
@@ -0,0 +1,34 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-cursor.
* Config maps to {@link CursorAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-cursor",
version: "0.5.0-alpha.4",
capabilities: ["cursor-cli", "workspace-agent"],
configSchema: {
type: "object",
required: ["command", "timeout"],
properties: {
command: {
type: "string",
description: "Absolute path to the cursor-agent CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Model identifier passed to cursor-agent --model; null means auto.",
},
timeout: {
type: "number",
description: "Timeout in milliseconds; 0 means no limit.",
},
workspace: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Override workspace path; null resolves from thread context.",
},
},
additionalProperties: false,
},
};
+4 -5
View File
@@ -1,12 +1,11 @@
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. */
/**
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
* from the thread via runtime extraction.
*/
workspace: string | null;
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
llmProvider: LlmProvider | null;
};
@@ -8,14 +8,11 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
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");
}
if (config.workspace === null && config.llmProvider === null) {
return err("llmProvider is required when workspace is null (needed for workspace extraction)");
}
if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
}
if (config.workspace !== null && !isAbsolute(config.workspace)) {
return err("workspace must be an absolute filesystem path when set");
}
return ok(undefined);
}
+5 -1
View File
@@ -6,5 +6,9 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
"references": [
{ "path": "../workflow-cas" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util-agent" }
]
}
@@ -0,0 +1,81 @@
# @uncaged/workflow-agent-hermes
## 0.5.0-alpha.4
### Patch Changes
- @uncaged/workflow-runtime@0.5.0-alpha.4
- @uncaged/workflow-util-agent@0.5.0-alpha.4
## 0.5.0-alpha.3
### Patch Changes
- @uncaged/workflow-runtime@0.5.0-alpha.3
- @uncaged/workflow-util-agent@0.5.0-alpha.3
## 0.5.0-alpha.2
### Patch Changes
- @uncaged/workflow-runtime@0.5.0-alpha.2
- @uncaged/workflow-util-agent@0.5.0-alpha.2
## 0.5.0-alpha.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util-agent@0.5.0-alpha.1
- @uncaged/workflow-runtime@0.5.0-alpha.1
## 0.5.0-alpha.0
### Patch Changes
- @uncaged/workflow-runtime@0.5.0-alpha.0
- @uncaged/workflow-util-agent@0.5.0-alpha.0
## 0.4.5
### Patch Changes
- @uncaged/workflow-runtime@0.4.5
- @uncaged/workflow-util-agent@0.4.5
## 0.4.4
### Patch Changes
- @uncaged/workflow-runtime@0.4.4
- @uncaged/workflow-util-agent@0.4.4
## 0.4.3
### Patch Changes
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
- Updated dependencies
- @uncaged/workflow-runtime@0.4.3
- @uncaged/workflow-util-agent@0.4.3
## 0.4.2
### Patch Changes
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
- Updated dependencies
- @uncaged/workflow-runtime@0.4.2
- @uncaged/workflow-util-agent@0.4.2
## 0.4.0
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-runtime@0.4.0
- @uncaged/workflow-util-agent@0.4.0
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-hermes");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
});
test("configSchema properties include command, model, timeout", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
});
});
+9 -5
View File
@@ -1,24 +1,28 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.3.21",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
+18 -10
View File
@@ -1,7 +1,7 @@
import type { AdapterFn } from "@uncaged/workflow-runtime";
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
import {
buildThreadInput,
createTextAdapter,
createAgentAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
@@ -11,6 +11,9 @@ import { validateHermesAgentConfig } from "./validate-config.js";
const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export { packageDescriptor } from "./package-descriptor.js";
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
@@ -29,16 +32,10 @@ function throwHermesSpawnError(error: SpawnCliError): never {
throw new Error("hermes: unknown spawn error");
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
const timeoutMs = config.timeout;
return createTextAdapter(async (ctx, prompt) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
return async (ctx, { prompt }) => {
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
@@ -61,5 +58,16 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
throwHermesSpawnError(run.error);
}
return run.value;
};
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
return { prompt };
});
}
@@ -0,0 +1,30 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-hermes.
* Config maps to {@link HermesAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-hermes",
version: "0.5.0-alpha.4",
capabilities: ["hermes-cli", "yolo-mode"],
configSchema: {
type: "object",
required: ["command"],
properties: {
command: {
type: "string",
description: "Absolute path to the hermes CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Model identifier passed to hermes --model; null uses the CLI default.",
},
timeout: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Timeout in milliseconds; null means no limit.",
},
},
additionalProperties: false,
},
};
+81
View File
@@ -0,0 +1,81 @@
# @uncaged/workflow-agent-llm
## 0.5.0-alpha.4
### Patch Changes
- @uncaged/workflow-runtime@0.5.0-alpha.4
- @uncaged/workflow-util-agent@0.5.0-alpha.4
## 0.5.0-alpha.3
### Patch Changes
- @uncaged/workflow-runtime@0.5.0-alpha.3
- @uncaged/workflow-util-agent@0.5.0-alpha.3
## 0.5.0-alpha.2
### Patch Changes
- @uncaged/workflow-runtime@0.5.0-alpha.2
- @uncaged/workflow-util-agent@0.5.0-alpha.2
## 0.5.0-alpha.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util-agent@0.5.0-alpha.1
- @uncaged/workflow-runtime@0.5.0-alpha.1
## 0.5.0-alpha.0
### Patch Changes
- @uncaged/workflow-runtime@0.5.0-alpha.0
- @uncaged/workflow-util-agent@0.5.0-alpha.0
## 0.4.5
### Patch Changes
- @uncaged/workflow-runtime@0.4.5
- @uncaged/workflow-util-agent@0.4.5
## 0.4.4
### Patch Changes
- @uncaged/workflow-runtime@0.4.4
- @uncaged/workflow-util-agent@0.4.4
## 0.4.3
### Patch Changes
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
- Updated dependencies
- @uncaged/workflow-runtime@0.4.3
- @uncaged/workflow-util-agent@0.4.3
## 0.4.2
### Patch Changes
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
- Updated dependencies
- @uncaged/workflow-runtime@0.4.2
- @uncaged/workflow-util-agent@0.4.2
## 0.4.0
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-runtime@0.4.0
- @uncaged/workflow-util-agent@0.4.0
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-llm");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires baseUrl, apiKey, model", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("baseUrl");
expect(required).toContain("apiKey");
expect(required).toContain("model");
});
test("configSchema properties include baseUrl, apiKey, model", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("baseUrl");
expect(props).toHaveProperty("apiKey");
expect(props).toHaveProperty("model");
});
});
+9 -5
View File
@@ -1,27 +1,31 @@
{
"name": "@uncaged/workflow-agent-llm",
"version": "0.3.21",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"devDependencies": {
"zod": "^4.0.0"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
@@ -1,5 +1,12 @@
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
import { createTextAdapter } from "@uncaged/workflow-util-agent";
import {
type AdapterFn,
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow-runtime";
import { createAgentAdapter } from "@uncaged/workflow-util-agent";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
@@ -91,9 +98,10 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value);
}
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt) => {
type LlmAgentOpt = { prompt: string };
function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
return async (ctx, { prompt }) => {
const result = await chatCompletionText({
provider,
messages: [
@@ -105,5 +113,12 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
}
return result.value;
});
};
}
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createAgentAdapter(createLlmAgent(provider), async (_ctx, prompt, _runtime) => ({
prompt,
}));
}
+1
View File
@@ -4,3 +4,4 @@ export {
type LlmChatError,
type LlmMessage,
} from "./create-llm-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
@@ -0,0 +1,30 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-llm.
* Config maps to {@link LlmProvider}: baseUrl + apiKey + model.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-llm",
version: "0.5.0-alpha.4",
capabilities: ["llm-single-turn"],
configSchema: {
type: "object",
required: ["baseUrl", "apiKey", "model"],
properties: {
baseUrl: {
type: "string",
description: "Base URL of the OpenAI-compatible chat completions endpoint.",
},
apiKey: {
type: "string",
description: "API key for the provider.",
},
model: {
type: "string",
description: "Model identifier passed as the `model` field in the request body.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,98 @@
# @uncaged/workflow-agent-react
## 0.5.0-alpha.4
### Patch Changes
- Updated dependencies [f74b482]
- Updated dependencies [f74b482]
- @uncaged/workflow-protocol@0.5.0-alpha.4
- @uncaged/workflow-reactor@0.5.0-alpha.4
- @uncaged/workflow-util-agent@0.5.0-alpha.4
## 0.5.0-alpha.3
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.3
- @uncaged/workflow-reactor@0.5.0-alpha.3
- @uncaged/workflow-util-agent@0.5.0-alpha.3
## 0.5.0-alpha.2
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.2
- @uncaged/workflow-reactor@0.5.0-alpha.2
- @uncaged/workflow-util-agent@0.5.0-alpha.2
## 0.5.0-alpha.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util-agent@0.5.0-alpha.1
- @uncaged/workflow-protocol@0.5.0-alpha.1
- @uncaged/workflow-reactor@0.5.0-alpha.1
## 0.5.0-alpha.0
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.0
- @uncaged/workflow-reactor@0.5.0-alpha.0
- @uncaged/workflow-util-agent@0.5.0-alpha.0
## 0.4.5
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.5
- @uncaged/workflow-reactor@0.4.5
- @uncaged/workflow-util-agent@0.4.5
## 0.4.4
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.4
- @uncaged/workflow-reactor@0.4.4
- @uncaged/workflow-util-agent@0.4.4
## 0.4.3
### Patch Changes
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
- Updated dependencies
- @uncaged/workflow-protocol@0.4.3
- @uncaged/workflow-reactor@0.4.3
- @uncaged/workflow-util-agent@0.4.3
## 0.4.2
### Patch Changes
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
- Updated dependencies
- @uncaged/workflow-protocol@0.4.2
- @uncaged/workflow-reactor@0.4.2
- @uncaged/workflow-util-agent@0.4.2
## 0.4.0
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.0
- @uncaged/workflow-reactor@0.4.0
- @uncaged/workflow-util-agent@0.4.0
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-react");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires maxRounds", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("maxRounds");
});
test("configSchema properties include maxRounds", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("maxRounds");
});
});
+11 -7
View File
@@ -1,31 +1,35 @@
{
"name": "@uncaged/workflow-agent-react",
"version": "0.3.21",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-reactor": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"devDependencies": {
"zod": "^4.0.0"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +1,5 @@
export { createReactAdapter } from "./create-react-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
export { defaultToolHandler, defaultTools } from "./tools/index.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
@@ -0,0 +1,25 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-react.
*
* Config represents the serializable subset of {@link ReactAdapterConfig}.
* The `llm` function and `toolHandler` are runtime constructs and are not
* stored in the CAS agent node; only `maxRounds` is serializable.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-react",
version: "0.5.0-alpha.4",
capabilities: ["react-loop", "tool-calling"],
configSchema: {
type: "object",
required: ["maxRounds"],
properties: {
maxRounds: {
type: "number",
description: "Maximum number of LLM ↔ tool-call rounds before the loop is terminated.",
},
},
additionalProperties: false,
},
};
+88
View File
@@ -0,0 +1,88 @@
# @uncaged/workflow-cas
## 0.5.0-alpha.4
### Patch Changes
- Updated dependencies
- Updated dependencies [f74b482]
- Updated dependencies [f74b482]
- @uncaged/workflow-util@0.5.0-alpha.4
- @uncaged/workflow-protocol@0.5.0-alpha.4
## 0.5.0-alpha.3
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.3
- @uncaged/workflow-util@0.5.0-alpha.3
## 0.5.0-alpha.2
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.2
- @uncaged/workflow-util@0.5.0-alpha.2
## 0.5.0-alpha.1
### Patch Changes
- @uncaged/workflow-protocol@0.5.0-alpha.1
- @uncaged/workflow-util@0.5.0-alpha.1
## 0.5.0-alpha.0
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.0
- @uncaged/workflow-util@0.5.0-alpha.0
## 0.4.5
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.5
- @uncaged/workflow-util@0.4.5
## 0.4.4
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.4
- @uncaged/workflow-util@0.4.4
## 0.4.3
### Patch Changes
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
- Updated dependencies
- @uncaged/workflow-protocol@0.4.3
- @uncaged/workflow-util@0.4.3
## 0.4.2
### Patch Changes
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
- Updated dependencies
- @uncaged/workflow-protocol@0.4.2
- @uncaged/workflow-util@0.4.2
## 0.4.0
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.0
- @uncaged/workflow-util@0.4.0
+9 -4
View File
@@ -1,7 +1,8 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.3.21",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
"package.json"
],
@@ -11,17 +12,21 @@
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"xxhashjs": "^0.2.2",
"yaml": "^2.7.1"
},
"devDependencies": {
"@types/bun": "latest"
},
"publishConfig": {
"access": "public"
}
}
+2 -15
View File
@@ -1,29 +1,16 @@
export { createCasStore } from "./cas.js";
export { collectRefs } from "./collect-refs.js";
export { hashString, hashWorkflowBundleBytes } from "./hash.js";
export { hashWorkflowBundleBytes } from "./hash.js";
export {
createContentMerkleNode,
getContentMerklePayload,
parseMerkleNode,
putContentMerkleNode,
putStepMerkleNode,
putThreadMerkleNode,
serializeMerkleNode,
} from "./merkle.js";
export type { ParsedCasThreadNode } from "./nodes.js";
export {
isCasNodeYaml,
parseCasThreadNode,
putContentNodeWithRefs,
putStartNode,
putStateNode,
serializeCasNode,
} from "./nodes.js";
export { findReachableHashes } from "./reachable.js";
export type {
CasStore,
MerkleNode,
MerkleNodeType,
StepMerklePayload,
ThreadMerklePayload,
} from "./types.js";
export type { CasStore } from "./types.js";
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"composite": true
},
"include": ["src"],
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
+29 -28
View File
@@ -26,11 +26,11 @@ function authHeaders(): Record<string, string> {
return {};
}
function agentBase(agent: string): string {
function clientBase(client: string): string {
if (GATEWAY_URL) {
return `${GATEWAY_URL}/api/agents/${agent}`;
return `${GATEWAY_URL}/api/clients/${client}`;
}
// Local dev: proxy via vite, no agent prefix
// Local dev: proxy via vite, no client prefix
return "/api";
}
@@ -57,7 +57,7 @@ async function fetchJson<T>(base: string, path: string): Promise<T> {
// ── Endpoint types ──────────────────────────────────────────────────
export type AgentEndpoint = {
export type ClientEndpoint = {
name: string;
url: string;
status: string;
@@ -122,6 +122,7 @@ export type WorkflowGraph = {
export type WorkflowRoleDescriptor = {
description: string;
systemPrompt: string;
schema: Record<string, unknown>;
};
@@ -141,61 +142,61 @@ export type WorkflowDetail = {
// ── Gateway endpoints ───────────────────────────────────────────────
export function listAgents(): Promise<AgentEndpoint[]> {
export function listClients(): Promise<ClientEndpoint[]> {
const url = GATEWAY_URL || "";
return fetchJson(url, "/api/gateway/endpoints");
}
// ── Agent-scoped endpoints ──────────────────────────────────────────
// ── Client-scoped endpoints ──────────────────────────────────────────
export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson(agentBase(agent), "/workflows");
export function listWorkflows(client: string): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson(clientBase(client), "/workflows");
}
export async function getWorkflowDetail(agent: string, name: string): Promise<WorkflowDetail> {
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`);
export async function getWorkflowDetail(client: string, name: string): Promise<WorkflowDetail> {
return fetchJson<WorkflowDetail>(clientBase(client), `/workflows/${encodeURIComponent(name)}`);
}
export async function getWorkflowDescriptor(
agent: string,
client: string,
name: string,
): Promise<WorkflowDescriptor | null> {
const res = await getWorkflowDetail(agent, name);
const res = await getWorkflowDetail(client, name);
return res.descriptor;
}
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads");
export function listThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(clientBase(client), "/threads");
}
export function listRunningThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads/running");
export function listRunningThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(clientBase(client), "/threads/running");
}
export function getThread(agent: string, id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(agentBase(agent), `/threads/${id}`);
export function getThread(client: string, id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(clientBase(client), `/threads/${id}`);
}
export function runThread(
agent: string,
client: string,
workflow: string,
prompt: string,
): Promise<{ threadId: string }> {
return postJson(agentBase(agent), "/threads", { workflow, prompt });
return postJson(clientBase(client), "/threads", { workflow, prompt });
}
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/kill`, {});
export function killThread(client: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(clientBase(client), `/threads/${threadId}/kill`, {});
}
export function pauseThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/pause`, {});
export function pauseThread(client: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(clientBase(client), `/threads/${threadId}/pause`, {});
}
export function resumeThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/resume`, {});
export function resumeThread(client: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(clientBase(client), `/threads/${threadId}/resume`, {});
}
export function getAgentHealth(agent: string): Promise<{ ok: boolean }> {
return fetchJson(agentBase(agent), "/healthz");
export function getClientHealth(client: string): Promise<{ ok: boolean }> {
return fetchJson(clientBase(client), "/healthz");
}
+24 -13
View File
@@ -6,12 +6,14 @@ import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowDetail } from "./components/workflow-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
import { useHashRoute } from "./use-hash-route.ts";
export function App() {
const [authed, setAuthed] = useState(hasApiKey());
const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute();
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
useHashRoute();
const [showRun, setShowRun] = useState(false);
if (!authed) {
@@ -22,36 +24,45 @@ export function App() {
<div className="flex h-screen">
<Sidebar
view={view}
agent={agent}
client={client}
onViewChange={setView}
onAgentChange={setAgent}
onClientChange={setClient}
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
<StatusBar client={client} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
{!agent && (
{!client && (
<div className="flex items-center justify-center h-full">
<p style={{ color: "var(--color-text-muted)" }}>
Select an agent from the sidebar to get started.
Select an client from the sidebar to get started.
</p>
</div>
)}
{agent && view === "threads" && threadId === null && (
<ThreadList agent={agent} onSelect={setThreadId} />
{client && view === "threads" && threadId === null && (
<ThreadList client={client} onSelect={setThreadId} />
)}
{agent && view === "threads" && threadId !== null && (
<ThreadDetail agent={agent} threadId={threadId} onBack={() => setThreadId(null)} />
{client && view === "threads" && threadId !== null && (
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
)}
{client && view === "workflows" && workflowName === null && (
<WorkflowList client={client} onSelect={setWorkflowName} />
)}
{client && view === "workflows" && workflowName !== null && (
<WorkflowDetail
client={client}
workflowName={workflowName}
onBack={() => setWorkflowName(null)}
/>
)}
{agent && view === "workflows" && <WorkflowList agent={agent} />}
</div>
</main>
{showRun && agent && (
{showRun && client && (
<RunDialog
agent={agent}
client={client}
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
@@ -3,7 +3,7 @@ import { Markdown } from "./markdown.tsx";
const ROLE_COLORS: Record<string, string> = {
preparer: "#8b5cf6",
agent: "#3b82f6",
client: "#3b82f6",
extractor: "#f59e0b",
};

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