Compare commits

..

437 Commits

Author SHA1 Message Date
xiaoju e67932c83c fix: accept omitted condition in fallback transitions
Fallback transitions (last entry in graph node) omit the condition
field in YAML, resulting in undefined instead of null. The validator
and materializer now handle this:

- validate.ts: accept undefined as valid condition value
- workflow.ts: normalizeGraph() coerces undefined → null before CAS put

This was broken by the graph fallback pattern introduced in #370.
2026-05-22 07:38:24 +00:00
xiaomo 04a12231c3 Merge pull request 'feat: register $first/$last JSONata functions in moderator' (#377) from feat/376-first-last-jsonata into main 2026-05-22 07:32:17 +00:00
xiaoju e5ae9a134c feat: register $first/$last JSONata functions in moderator
Register custom $first(role) and $last(role) functions in the JSONata
evaluator. These search the steps array and return the matching role's
frontmatter (output) directly, replacing verbose steps[-1].output.x
expressions with semantic $last('role').field syntax.

- workflow-moderator: register functions via expr.registerFunction()
- Updated all condition expressions in .workflows/ and examples/
- Added tests for $last, $first, and unmatched role (undefined)

Fixes #376
2026-05-22 06:29:56 +00:00
xiaomo bdafaf3aa1 Merge pull request 'refactor!: rename RoleDefinition.meta → frontmatter' (#375) from refactor/374-meta-to-frontmatter into main 2026-05-22 06:06:06 +00:00
xiaoju 02f7f0b708 refactor!: rename RoleDefinition.meta → frontmatter
BREAKING CHANGE: All workflow YAML files must use 'frontmatter' instead of 'meta'.

- workflow-protocol: RoleDefinition.meta → frontmatter, schema updated
- cli-workflow: validate.ts, workflow.ts — resolveMetaRef → resolveFrontmatterRef
- workflow-agent-kit: run.ts — metaSchema → frontmatterSchema
- All YAML files updated (examples/, .workflows/)

Fixes #374
2026-05-22 06:05:07 +00:00
xiaoju 8ea554bb5e Merge pull request 'feat: create .workflows/solve-issue.yaml' (#372) from feat/370-solve-issue-workflow into main 2026-05-22 06:02:15 +00:00
xiaoju 8a425521da fix: output instructions now specify required frontmatter meta fields 2026-05-22 05:42:17 +00:00
xiaoju f174f2fd0a fix: remove redundant condition null from $START 2026-05-22 05:33:39 +00:00
xiaoju 355594d074 refactor: graph fallback pattern + positive condition names
- Last transition in each graph node is now the fallback (no condition)
- Remove redundant positive conditions (ready, devDone, approved, passed, pushSuccess)
- notApproved → rejected (positive naming)
2026-05-22 05:31:43 +00:00
xiaoju fd7609fe90 fix: address review feedback from xingyue
1. npm/npx → bun/bunx (project standard)
2. Fix tea CLI usage (tea comment + -r flag)
3. cursor-agent → coding (abstract capability)
4. Clarify committer inherits developer's worktree
5. Mark meta.plan required when status=ready
6. PR description must follow What/Why/Changes/Ref template
7. Note maxRounds loop protection in description
2026-05-22 05:27:21 +00:00
xiaoju dacecfbbb7 feat: create .workflows/solve-issue.yaml
TDD-driven issue resolution workflow with 5 roles:
- planner: analyzes issue, outputs TDD test spec (stored in CAS)
- developer: implements code following TDD
- reviewer: code standards compliance check (not functionality)
- tester: functional correctness verification
- committer: commits and creates PR

Graph handles bounce-backs: reviewer→developer, tester→developer,
tester→planner (fix_spec), committer→developer (hook_failed).

Refs #370
2026-05-22 05:21:19 +00:00
xiaomo 3238eaeddf Merge pull request 'feat: add uwf skill cli command and Prepare section' (#371) from feat/369-uwf-skill-cli into main 2026-05-22 04:50:12 +00:00
xiaoju 995f273fa5 address review: move CLI reference to workflow-util, inline in prompt
- Move generateCliReference() to @uncaged/workflow-util
- buildRolePrompt inlines CLI reference directly (no agent tool call)
- Fix Role terminology to use new field names
- Add maintenance comment in cli-reference.ts
- Fix test assertions
2026-05-22 03:29:01 +00:00
xiaoju 866154ad73 feat: add uwf skill cli command and Prepare section in role prompt
- Add 'uwf skill cli' command that prints markdown CLI reference
- buildRolePrompt now generates ## Prepare section:
  - Always prompts agent to run 'uwf skill cli' (explicit skill)
  - Renders capabilities as keyword hints for implicit skill loading

Fixes #369
2026-05-22 03:20:04 +00:00
xiaomo 8efc5050cb Merge pull request 'chore: exclude legacy code from biome check' (#368) from chore/ignore-legacy-biome into main 2026-05-22 02:10:20 +00:00
xiaoju 3fb60ee649 chore: exclude legacy-packages and scripts from biome check
- Add legacy-packages/ and scripts/ to biome ignore
- Allow noDefaultExport in vitest.config.* and .d.ts
- Allow console in cli.ts and setup.ts (CLI user output)
- Fix unused imports in cas.ts and setup.ts
2026-05-22 02:09:18 +00:00
xiaomo e181f67a2d Merge pull request 'feat: support project-local workflow discovery' (#367) from feat/365-project-local-workflows into main 2026-05-22 02:07:33 +00:00
xiaoju a3114bf840 chore: apply biome formatting across codebase 2026-05-22 02:06:05 +00:00
xiaoju e59ae9aca1 feat: support project-local workflow discovery
- Add .workflows/*.yaml scanning from project root (cwd)
- Resolution: project-local first, then global registry
- On-the-fly CAS materialization for local workflows
- Filename/name consistency check
- uwf workflow list shows origin (local/global)

Fixes #365
2026-05-22 01:01:45 +00:00
xiaomo c050a38f38 Merge pull request 'refactor: rename RoleDefinition fields for clarity' (#366) from refactor/364-rename-role-fields into main 2026-05-22 00:48:23 +00:00
xiaoju c60c310074 refactor: rename RoleDefinition fields for clarity
- identity → goal
- prepare → capabilities (string[])
- execute → procedure
- report → output
- outputSchema → meta

Fixes #364
2026-05-22 00:46:06 +00:00
xiaomo fe035c065d Merge pull request 'feat: Role 四段式描述 (identity/prepare/execute/report)' (#361) from feat/359-role-four-phase into main 2026-05-21 03:11:00 +00:00
xiaoju 192ad656a4 refactor: remove systemPrompt, make four-phase fields required
Breaking change per review:
- Remove systemPrompt from RoleDefinition entirely
- identity/prepare/execute/report are now required (string, not nullable)
- Remove all legacy fallback logic in buildRolePrompt
- Simplify validate.ts, workflow.ts materialize
- Migrate all test fixtures and example workflows

Refs #359
2026-05-21 03:07:56 +00:00
xiaoju c0c8d6499e feat: add four-phase example workflow (analyze-topic)
Refs #359, #363
2026-05-21 02:56:11 +00:00
xiaoju 505f85e3c4 feat: add buildRolePrompt in agent-kit, integrate with uwf-hermes
- New buildRolePrompt() in workflow-agent-kit: four-phase prompt assembly
  with fallback to systemPrompt
- Export from agent-kit index
- Update uwf-hermes to use buildRolePrompt instead of raw systemPrompt
- Add tests for all modes: four-phase, legacy, mixed

Refs #359, #362
2026-05-21 02:31:56 +00:00
xiaoju fc7d482b4f feat: add four-phase role description (identity/prepare/execute/report)
- Extend RoleDefinition with identity, prepare, execute, report fields
- Make systemPrompt optional (nullable) for four-phase workflows
- Update ROLE_DEFINITION JSON Schema (all new fields optional)
- Update validate.ts to accept new fields
- Update workflow.ts to strip null fields before CAS storage
- Update thread read to prefer identity over systemPrompt
- Add --version flag to uwf CLI
- Bump all packages to 0.5.0

Refs #359
2026-05-21 01:41:20 +00:00
xiaoju f9979c3c89 chore: upgrade json-cas to 0.4.x, fix Store → BootstrapCapableStore
- @uncaged/json-cas ^0.3.0 → ^0.4.0
- @uncaged/json-cas-fs ^0.3.0 → ^0.4.0 (now publishes .d.ts + .js)
- UwfStore.store typed as BootstrapCapableStore
- tsc --build now clean (no more node_modules type errors)

小橘 🍊(NEKO Team)
2026-05-19 10:29:57 +00:00
xiaoju 46def2945a chore: update dev workflow — fix publish script, remove deploy.sh, update CLAUDE.md
- scripts/publish-all.mjs: update to 6 active packages only
- scripts/deploy.sh: removed (dashboard/gateway in legacy)
- package.json: release script uses publish-all.mjs directly
- CLAUDE.md: add complete dev workflow section (setup, build, check, test, publish)

小橘 🍊(NEKO Team)
2026-05-19 08:07:45 +00:00
xiaoju 4e89508246 docs: rewrite README.md and CLAUDE.md for current architecture
Remove all references to ESM bundles, old packages, old CLI name.
Update to reflect YAML workflow definitions, uwf CLI, 6 active packages,
frontmatter markdown output format, and stateless single-step execution.

小橘 🍊(NEKO Team)
2026-05-19 08:03:13 +00:00
xiaoju 77d799d458 chore: remove obsolete .env.example, config via uwf setup
小橘 🍊(NEKO Team)
2026-05-19 07:58:50 +00:00
xiaoju 6c14259184 chore: remove pnpm-lock.yaml files, bun only
小橘 🍊(NEKO Team)
2026-05-19 07:58:24 +00:00
xiaoju 7b9cb6a9c8 chore: rename uwf-* → workflow-*, cli-uwf → cli-workflow
Reclaim the workflow-* package names now that legacy packages are archived.

Package renames:
- @uncaged/uwf-protocol → @uncaged/workflow-protocol
- @uncaged/uwf-moderator → @uncaged/workflow-moderator
- @uncaged/uwf-agent-kit → @uncaged/workflow-agent-kit
- @uncaged/uwf-agent-hermes → @uncaged/workflow-agent-hermes
- @uncaged/cli-uwf → @uncaged/cli-workflow

All internal imports, tsconfig references, and docs updated.
CLI binary name 'uwf' unchanged.

小橘 🍊(NEKO Team)
2026-05-19 07:52:16 +00:00
xiaoju 68246e20b1 fix: remove workflow-util dependency on workflow-protocol
Inline Result type and ok/err helpers into workflow-util to break
dependency on the now-archived workflow-protocol package.

Also add explicit @uncaged/json-cas dep to uwf-protocol (was only
available as transitive dep via json-cas-fs).

小橘 🍊(NEKO Team)
2026-05-19 07:22:15 +00:00
xiaoju d63d58ccb5 chore: reorganize repo — legacy packages to legacy-packages/, templates to examples/
- Move 15 old workflow-* packages to legacy-packages/ (inactive, preserved for reference)
- Rename templates/ → examples/ for clarity
- Rewrite docs/architecture.md to reflect current uwf architecture
- Active packages remain in packages/: cli-uwf, uwf-agent-hermes, uwf-agent-kit, uwf-moderator, uwf-protocol, workflow-util

小橘 🍊(NEKO Team)
2026-05-19 07:19:40 +00:00
xiaomo 2a3a40b9d9 Merge pull request 'feat(cli-uwf): thread read Content + step-details — #357' (#358) from feat/357-thread-read-content into main 2026-05-19 06:58:24 +00:00
xiaoju 762ecec872 feat(cli-uwf): thread read shows Content + new step-details command
- thread read: add ### Content section (last assistant message) before ### Output
- Remove --detail flag (replaced by step-details command)
- New: uwf thread step-details <step-hash> — full detail dump as yaml

Closes #357
2026-05-19 06:44:18 +00:00
xiaoju c0ac4ade09 fix(uwf-agent-hermes): consume outputFormatInstruction in prompt
buildHermesPrompt was ignoring ctx.outputFormatInstruction — the
deliverable format and scope constraint were injected into context
but never passed to the agent.

Now prepends it before systemPrompt (deliverable-first principle).

Refs #355
2026-05-19 06:23:13 +00:00
xiaomo a991393053 Merge pull request 'feat(uwf-agent-kit): frontmatter fast path + prompt injection — #355' (#356) from feat/355-uwf-frontmatter into main 2026-05-19 06:21:35 +00:00
xiaoju 892ccab8d5 feat(uwf-agent-kit): frontmatter fast path + prompt injection
Port RFC #351 frontmatter markdown to uwf-* path:
- tryFrontmatterFastPath(): parse → validate → JSON Schema check via json-cas
- Happy path skips LLM extract, fallback to existing extract()
- buildOutputFormatInstruction(): generates deliverable format from JSON Schema
- Injected into agent context before execution
- Scope reminder: 'Focus exclusively on YOUR role's deliverable'
- 14 new tests (vitest)

Closes #355
2026-05-19 06:20:15 +00:00
xiaomo 70c83c65b0 Merge pull request 'feat(workflow-util-agent): prompt restructure + scope focus — RFC #351 Phase 3' (#354) from feat/351-phase3-prompt-focus into main 2026-05-19 05:57:37 +00:00
xiaoju 8a7e756fe3 feat(workflow-util-agent): prompt restructure + scope focus — Phase 3
- buildOutputFormatInstruction(schema): auto-generates frontmatter
  format guide from Zod schema, injected at top of system prompt
- Adapter prepends deliverable format before role's systemPrompt
- buildThreadInput reordered: Task → Steps → Parent → Tools
- Scope reminder: 'Focus exclusively on YOUR role's deliverable'
- 8 tests for buildOutputFormatInstruction

Refs #351
2026-05-19 05:56:27 +00:00
xiaomo 4a4ddba9f6 Merge pull request 'feat(workflow-util-agent): two-layer frontmatter safeguard — RFC #351 Phase 2' (#353) from feat/351-phase2-adapter-frontmatter into main 2026-05-19 05:47:46 +00:00
xiaoju d5f47d1a18 feat(workflow-util-agent): two-layer frontmatter safeguard in adapter
Phase 2 of RFC #351 — adapter tries frontmatter first (zero LLM cost),
falls back to runtime.extract() when frontmatter is missing/invalid.

- tryFrontmatterMeta(): parse → validate → schema.safeParse
- Happy path stores body (no frontmatter) in CAS
- Fallback stores full raw in CAS + LLM extract
- 5 tests covering both paths

Refs #351
2026-05-19 05:46:36 +00:00
xiaoju 37c35560e9 docs: fix parseMinimalYaml JSDoc (nit from #352 review)
Refs #351
2026-05-19 05:41:18 +00:00
xiaomo f174b96028 Merge pull request 'feat(workflow-util): frontmatter markdown parser — RFC #351 Phase 1' (#352) from feat/351-frontmatter-markdown-phase1 into main 2026-05-19 04:56:58 +00:00
xiaoju 43978360ff feat(workflow-util): add frontmatter markdown parser and validator
Phase 1 of RFC #351 — define AgentFrontmatter type, parseFrontmatterMarkdown()
and validateFrontmatter() with 45 tests.

- Built-in minimal YAML parser (no new deps)
- Never throws on malformed input — degrades gracefully
- All fields use T | null (no optional properties)

Refs #351
2026-05-19 04:41:56 +00:00
xiaomo 432400ee20 Merge pull request 'feat: uwf thread read — human-readable markdown with pagination' (#350) from feat/349-thread-read into main 2026-05-19 03:45:02 +00:00
xiaoju dacebe1841 feat(thread-read): show role system prompt in each step
Each step block now includes a '### Prompt' section showing the
role's systemPrompt from the workflow definition.

Refs #349
2026-05-19 03:23:50 +00:00
xiaoju c42125946d feat(thread-read): expand detail recursively via cas_ref
--detail now uses expandDeep to recursively resolve all cas_ref
fields in the detail merkle tree, showing full turn content
instead of raw hashes.

Refs #349
2026-05-19 03:19:40 +00:00
xiaoju 4c9ce72395 feat: uwf thread read — human-readable markdown with pagination
- Outputs markdown directly (not JSON/YAML)
- --quota <chars>: character budget, loads steps backward until exceeded (default 4000)
- --before <step-hash>: load steps before this hash (exclusive), omits start
- --start: force include start section even with --before
- --detail: expand detail CAS node content for each step
- Skip hint with uwf thread read command for pagination
- Reuses walkChain/collectOrderedSteps/expandOutput

Closes #349
2026-05-19 03:15:38 +00:00
xiaomo 8b43f7993b Merge pull request 'fix: parse session_id from stderr — hermes --quiet writes it there' (#348) from fix/348-session-id-stderr into main 2026-05-18 17:10:29 +00:00
xiaoju cf9e2cd3d6 fix: parse session_id from stderr (hermes --quiet writes it there)
hermes --quiet outputs session_id to stderr and AI response to stdout.
The agent was only parsing stdout, so session_id was never found and
detail always fell back to raw output.

Now checks stderr first, then stdout as fallback.
2026-05-18 17:05:54 +00:00
xiaomo 7a99c1a9d6 Merge pull request 'fix: hermes agent empty detail — parse session_id from any line' (#347) from fix/342-parse-session-id into main 2026-05-18 16:58:24 +00:00
xiaoju 546237db85 fix: parseSessionIdFromStdout scans all lines, not just last
--quiet mode outputs session_id on the first line, not the last.
The old code only checked the last non-empty line and broke immediately
if it didn't match, causing session detail to always be empty.

Fixes the empty detail bug when hermes agent runs in quiet mode.
2026-05-18 16:57:24 +00:00
xiaomo 1ed7e32067 Merge pull request 'simplify: thread fork only takes step-hash' (#346) from fix/342-fork-simplify into main 2026-05-18 16:43:33 +00:00
xiaoju bd5e5a435b simplify: thread fork only takes step-hash
Remove thread-id argument — CAS node is self-contained, no need to
specify which thread it belongs to. Just verify the hash is a valid
StartNode or StepNode.

Refs #342
2026-05-18 16:38:55 +00:00
xiaomo 67e689ff1a Merge pull request 'feat: thread steps + thread fork' (#345) from feat/342-thread-steps-fork into main 2026-05-18 16:34:55 +00:00
xiaoju 06eb2dff3b feat: add thread steps and thread fork commands
- uwf thread steps <thread-id>: walk CAS chain, list all steps chronologically
- uwf thread fork <thread-id> <step-hash>: create new thread from history point
- New types: StartEntry, StepEntry, ThreadStepsOutput, ThreadForkOutput
- Supports both active and archived threads

Refs #342
2026-05-18 16:30:12 +00:00
xiaomo a2bd3126c8 Merge pull request 'refactor: AgentContext extends ModeratorContext, remove redundant fields' (#341) from refactor/simplify-agent-context into main 2026-05-18 16:17:16 +00:00
xiaoju 710d42d6b9 refactor(agent-kit): base AgentContext on ModeratorContext
AgentContext now extends ModeratorContext (start + steps) with threadId, role, store, and expanded workflow. Hermes and mock-agent read prompt/steps/systemPrompt from the new shape.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 16:14:13 +00:00
xiaomo 072d900fcb Merge pull request 'refactor: pass store via AgentContext, eliminate duplicate store instances' (#340) from refactor/pass-store-via-context into main 2026-05-18 16:05:38 +00:00
xiaoju cfebd07124 refactor(agent-kit): pass CAS store through AgentContext
Expose the store created during context build on AgentContext so agents
reuse the same in-memory cache instead of opening a second store.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 16:04:15 +00:00
xiaoju f2be6fc057 Merge pull request 'feat: hermes merkle detail — session turns as CAS tree (Phase 2 of #337)' (#339) from feat/337-agent-detail-merkle into main 2026-05-18 15:58:01 +00:00
xiaoju d392563549 feat(uwf-hermes): Phase 2 merkle detail from Hermes session JSON
Parse session_id from Hermes stdout, store hermes-turn leaves and
hermes-detail root in CAS with cas_ref turns; fall back to raw stdout
when the session file is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:56:50 +00:00
xiaoju 2af8196451 Merge pull request 'feat: agent-kit interface change — agents own their detail (Phase 1 of #337)' (#338) from feat/337-agent-detail-merkle into main 2026-05-18 15:52:56 +00:00
xiaoju ad74768630 feat(uwf-agent): Phase 1 agent returns output and detailHash
- Change AgentRunFn to return { output, detailHash } instead of raw string

- Remove agent-kit detail CAS write; agents store their own detail nodes

- Hermes stores raw output as typed hermes-raw-output CAS node

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:29:48 +00:00
xiaoju a38ca7e8db chore: upgrade json-cas deps to ^0.3.0 2026-05-18 15:27:01 +00:00
xiaomo 3d97968887 Merge pull request 'feat: add uwf cas reindex command' (#334) from feat/cas-reindex into main 2026-05-18 14:25:38 +00:00
xiaoju ade6227ffe feat: add uwf cas reindex command
Rebuilds _index from all .bin nodes. Use after upgrading to json-cas 0.2.0
on an existing CAS directory.
2026-05-18 14:24:23 +00:00
xiaomo 13789e2c66 Merge pull request 'refactor: use listByType for schema list, upgrade json-cas to 0.2.0' (#333) from refactor/use-list-by-type into main 2026-05-18 14:18:16 +00:00
xiaoju 6758adc1d5 refactor: use listByType for schema list, upgrade json-cas to 0.2.0
Replace O(n) full CAS scan with O(1) type-index lookup.

Refs #328
2026-05-18 14:16:15 +00:00
xiaomo 7c12015855 Merge pull request 'refactor: merge cas get/cat into get, default hides timestamp' (#332) from refactor/merge-cas-get-cat into main 2026-05-18 14:03:50 +00:00
xiaoju 0f6859678c refactor: merge cas get/cat into get, default hides timestamp
- Remove `cas cat` command
- `cas get` now returns {type, payload} by default
- Add `--timestamp` flag to include timestamp

Refs #328
2026-05-18 14:03:10 +00:00
xiaomo 84798510b0 Merge pull request 'refactor: remove table output format, keep json and yaml only' (#331) from refactor/remove-table-format into main 2026-05-18 13:59:12 +00:00
xiaoju 6eace09826 refactor: remove table output format, keep json and yaml only
Table format adds complexity without readability gain over yaml.

Refs #328
2026-05-18 13:57:46 +00:00
xiaomo cb39a6693a Merge pull request 'fix: table format without header row' (#330) from fix/328-table-vertical into main 2026-05-18 13:48:17 +00:00
xiaoju 36d120b745 fix: table format — horizontal for arrays, vertical for objects
Arrays: horizontal table with HEADER row
Objects: vertical KEY/VALUE table
Primitives: fall back to yaml

小橘 🍊(NEKO Team)
2026-05-18 13:43:50 +00:00
jiayi 86dd37b0c8 Merge pull request 'feat: add office-agent document workflow (template + writer + differ)' (#327) from user/jiayiyan/feat_office-agent-document-template-v2 into main
Reviewed-on: #327
2026-05-18 13:42:03 +00:00
xiaomo bb0f2ca678 Merge pull request 'feat: --format json/yaml/table for all non-interactive commands' (#329) from feat/328-format-option into main 2026-05-18 13:40:19 +00:00
xiaomo ec0bc672f6 Merge pull request 'feat: --format json/yaml/table for all non-interactive commands' (#329) from feat/328-format-option into main 2026-05-18 13:36:02 +00:00
jiayiyan f08ba6914c chore: remove .DS_Store and add to .gitignore 2026-05-18 21:35:40 +08:00
xiaoju 7dd6ab5328 feat: --format json/yaml/table for all non-interactive commands
Add program-level --format option (default: json) inherited by all
subcommands. json output unchanged, yaml via yaml package, table
renders aligned columns for arrays, falls back to yaml for objects.

Closes #328

小橘 🍊(NEKO Team)
2026-05-18 13:33:41 +00:00
jiayiyan f6dd4d59a1 docs: add office-agent document template spec and implementation plan 2026-05-18 21:26:11 +08:00
jiayiyan d8cdc8ab88 feat(agent): add workflow-agent-office runner with generate/edit and tests 2026-05-18 21:26:11 +08:00
jiayiyan 20ddc5d7aa docs(architecture): add workflow-agent-office, workflow-agent-docx-diff, workflow-template-document
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
jiayiyan 2846311f8d feat(agent): add workflow-agent-docx-diff with docx-diff AdapterFn
Implements createDocxDiffAgent (AdapterFn), packageDescriptor, and exports in index.ts; 9 tests pass (runner 6 + agent 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
jiayiyan ed0043b8ac feat(agent): scaffold workflow-agent-docx-diff package
Add package.json, tsconfig.json, and placeholder src/index.ts for
@uncaged/workflow-agent-docx-diff; append reference in root tsconfig.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
jiayiyan bee3911f3f feat(agent): add workflow-agent-office with generate/edit AdapterFn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
jiayiyan 4285b8b180 feat(agent): scaffold workflow-agent-office package
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
xiaomo 7c955fa749 Merge pull request 'fix: uwf cas — JSON output + meta-schema in schema list' (#326) from fix/319-cas-json-output into main 2026-05-18 13:25:16 +00:00
jiayiyan f0b7be79fb feat(template): add workflow-template-document with writer/differ roles and moderator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:24:58 +08:00
jiayiyan d4f05adeba chore(template): scaffold workflow-template-document package
Add package.json, tsconfig.json, and placeholder src/index.ts for the
@uncaged/workflow-template-document package; register it in root tsconfig.json references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:24:58 +08:00
xiaoju c4c9f96117 fix: uwf cas commands output JSON, include meta-schema in schema list
All cas subcommands now output JSON via writeJson(), consistent with
other uwf commands. schema list includes meta-schema. Removed --json
flag and --format tree (tree is human-only, not machine-friendly).

Refs #319

小橘 🍊(NEKO Team)
2026-05-18 13:24:19 +00:00
xiaomo 633d5aeafe Merge pull request 'refactor: outputSchema only accepts inline JSON Schema' (#325) from fix/319-validate-schema-only-inline into main 2026-05-18 13:18:17 +00:00
xiaoju 17103c1ee1 refactor: outputSchema only accepts inline JSON Schema
- Remove CAS ref string support from workflow YAML outputSchema
- Simplify validate.ts: no string check for outputSchema
- Auto-set title from role name (workflow.role format)

Refs #319

小橘 🍊(NEKO Team)
2026-05-18 13:17:29 +00:00
xiaomo c8a39be9bd Merge pull request 'fix: remove cas list, add schema titles' (#324) from fix/319-schema-titles into main 2026-05-18 13:07:15 +00:00
xiaoju b304f65876 feat: auto-set outputSchema title from role name
When uwf workflow put processes inline JSON Schema for a role,
auto-inject title=roleName if not already set. Makes uwf cas schema list
show meaningful names like 'planner', 'coder' instead of (unnamed).

小橘 🍊(NEKO Team)
2026-05-18 13:05:28 +00:00
xiaoju c9010a024f fix: remove cas list, add title to schemas
- Remove uwf cas list (CAS grows unbounded, listing all hashes is useless)
- Add title to Workflow/StartNode/StepNode schemas so schema list shows names

小橘 🍊(NEKO Team)
2026-05-18 13:01:17 +00:00
xiaomo 3434e2b2be Merge pull request 'feat: built-in uwf cas commands replacing json-cas passthrough' (#323) from feat/319-uwf-cas-builtin into main 2026-05-18 12:49:18 +00:00
xiaoju 52282e1960 feat: built-in uwf cas commands replacing json-cas passthrough
- get, cat, put, has, list, refs, walk, schema list, schema get
- All commands auto-resolve store to ~/.uncaged/workflow/cas
- No external json-cas CLI dependency needed
- Agent-friendly: uwf cas --help shows all available subcommands

Refs #319, Closes #320

小橘 🍊(NEKO Team)
2026-05-18 12:40:15 +00:00
Scott Wei 7a579ee67a feat: uwf cas — passthrough to json-cas with uwf store path
uwf cas get <hash>, uwf cas list, etc. all auto-set --store to
~/.uncaged/workflow/cas so agents don't need to remember the path.

小橘 🍊(NEKO Team)
2026-05-18 20:14:59 +08:00
Scott Wei 7c230383ad improve: multi-column model list + friendly post-setup message
- Model list now renders in columns to fit terminal width
- Interactive setup ends with usage hints instead of JSON dump

小橘 🍊(NEKO Team)
2026-05-18 19:56:09 +08:00
xiaoju e604fa5f47 feat: add uwf setup command
- Interactive mode: prompts for provider, API key, model (with /models discovery)
- Non-interactive mode: --provider --base-url --api-key --model flags
- Writes config.yaml (providers, models, agents, defaults)
- Writes .env (API keys with auto-generated env var names)
- Merges into existing config non-destructively
- Includes 13 preset providers (international + China + local)

小橘 🍊(NEKO Team)
2026-05-18 11:49:42 +00:00
xiaoju 5580791686 chore: remove stale develop-entry.ts 2026-05-18 11:43:09 +00:00
xiaoju 3afd7a5319 chore: remove leftover smoke test files 2026-05-18 11:41:49 +00:00
xiaoju 3d1b2268b4 chore: bump json-cas deps to ^0.1.3 2026-05-18 10:48:06 +00:00
xiaoju 8bebe9da0f chore: bump json-cas-fs to ^0.1.2 (fix workspace:^ in published pkg) 2026-05-18 10:44:30 +00:00
xiaoju 53a7355f0b chore: fix json-cas workspace:^ refs to ^0.1.1 2026-05-18 10:30:31 +00:00
xiaoju d99c285725 chore: remove cross-repo json-cas workspace deps from root 2026-05-18 10:28:22 +00:00
xiaoju 2505dd8d6a chore: remove stale pnpm-lock.yaml 2026-05-18 10:25:45 +00:00
xiaomo 1121dfa48b Merge pull request 'feat: uwf — Stateless Workflow CLI' (#317) from feat/309-uwf-stateless into main 2026-05-18 10:07:55 +00:00
xiaoju d90e29ad05 fix: address 3 critical PR review issues
1. threads.yaml race condition: reload threads index after agent subprocess
   completes before updating head pointer (cli-uwf/commands/thread.ts)

2. evaluateJsonata not awaited: jsonata evaluate() returns Promise for async
   expressions — now properly awaited (uwf-moderator/evaluate.ts)

3. resolveWorkflowHash dead code: function always returns a value, removed
   impossible null return type and dead null-check branches at call sites
   (cli-uwf/store.ts, commands/thread.ts, commands/workflow.ts)
2026-05-18 10:05:11 +00:00
xiaoju 0727e0e8d5 fix: reload CAS store after agent spawn + share schemas via uwf-protocol
The agent subprocess writes StepNode to CAS on disk, but the parent
process had an in-memory cache from createFsStore init. Fix: re-create
store after agent spawn to pick up new nodes.

Also centralized JSON Schemas in uwf-protocol so cli-uwf and agent-kit
produce identical type hashes.

E2E smoke test passing: workflow put → thread start → 3x step → done

Refs #309
2026-05-18 09:33:52 +00:00
xiaoju ba012d98bc feat: add @uncaged/uwf-agent-hermes — Hermes agent CLI adapter
Spawns 'hermes chat' with assembled prompt from agent-kit context.
Agent-kit handles extract, StepNode write, and stdout output.

Refs #309, #316
2026-05-18 09:22:12 +00:00
xiaoju b165049a13 feat: implement thread step — moderator → agent → update head
- Walk CAS chain to build ModeratorContext with expanded output
- Call uwf-moderator evaluate() for role decision
- Agent resolution: --agent > config overrides > default
- Spawn agent CLI, capture StepNode hash
- Update threads.yaml, check done via second evaluate
- Archive on $END

Refs #309, #315
2026-05-18 09:19:37 +00:00
xiaoju 4d477c67c0 feat: add @uncaged/uwf-agent-kit — agent CLI framework
- createAgent() API for building agent CLIs
- Context builder: reads CAS chain, builds AgentContext
- Extract: LLM-based structured output extraction
- StepNode writer: writes to CAS without touching threads.yaml
- Stdout: outputs StepNode hash

Refs #309, #314
2026-05-18 09:15:25 +00:00
xiaoju 0d5678c961 feat: add thread start/show/list/kill commands
- thread start: ULID generation, StartNode to CAS, threads.yaml
- thread show: active (done:false) or archived (done:true)
- thread list: active threads, --all includes history
- thread kill: archive to history.jsonl

Refs #309, #313
2026-05-18 09:09:10 +00:00
xiaoju a8e2aa85f8 feat: add @uncaged/cli-uwf with workflow put/show/list commands
Refs #309, #312
2026-05-18 09:03:55 +00:00
xiaoju 2a4d35399b feat: add @uncaged/uwf-moderator with JSONata evaluation engine
5 tests passing:  transition, condition match, fallback,
missing role error, output expansion.

Refs #309, #311
2026-05-18 08:58:21 +00:00
xiaoju 391915411e feat: add @uncaged/uwf-protocol with all shared types
Refs #309, #310
2026-05-18 08:53:37 +00:00
scottwei 4aaf49bfc6 Merge pull request 'jshang/optimize-dashboard-ui' (#308) from jshang/optimize-dashboard-ui into main
Reviewed-on: #308
2026-05-18 08:45:46 +00:00
xiaoju 08de1ae5eb docs: fresh uwf-* packages, depend on @uncaged/json-cas, no reuse 2026-05-18 08:44:04 +00:00
xiaoju c91a3d1ec6 docs: add description to condition definitions 2026-05-18 08:41:29 +00:00
xiaoju 13d932f69c docs: config with provider/model/agent registries and alias-based overrides 2026-05-18 08:38:08 +00:00
jiashuang f705d9b8ea refactor: optimize ui for dashboard 2026-05-18 16:20:05 +08:00
xiaoju f84d327410 docs: add .env for API keys, separate from config.yaml 2026-05-18 08:19:48 +00:00
xiaoju 9c2f93629b docs: add models config (default + extract LLM) 2026-05-18 08:16:03 +00:00
xiaoju bcefcb9af7 docs: add section 4 — key data types with shared StepRecord 2026-05-18 08:13:18 +00:00
xiaoju b14dce2bc6 docs: fix inconsistencies — title, terminology, threads.yaml, JSONata context 2026-05-18 08:09:40 +00:00
xiaoju 85c572e770 docs: inline roles/moderator into Workflow, output as cas_ref, detail polymorphic 2026-05-18 08:07:20 +00:00
xiaoju 9a89885ce6 docs: rewrite CAS structure — flatten refs, named conditions, config.yaml, output naming 2026-05-18 07:55:04 +00:00
xiaoju d095ceaafa docs: agent CLI takes thread-id + role, outputs CAS hash, step owns pointer 2026-05-18 07:24:14 +00:00
xiaoju 2a0346f48b docs: simplify show to pure thread-id → head query, all output JSON 2026-05-18 07:18:29 +00:00
xiaoju b4e25ea002 docs: add done field to step output 2026-05-18 07:12:38 +00:00
xiaoju 77f2060e6b docs: step on ended thread is an error, not null head 2026-05-18 07:11:50 +00:00
xiaoju 8f9a925179 docs: simplify step output to workflow/thread/head 2026-05-18 07:10:11 +00:00
jiashuang 2f3fff3536 refactor: introduce react-router 2026-05-18 15:06:16 +08:00
xiaoju a7eb9814ae docs: fix agent invocation format in thread step 2026-05-18 07:05:35 +00:00
xiaoju a8024e6d42 docs: use full 26-char Crockford Base32 ULIDs for thread IDs 2026-05-18 07:03:40 +00:00
xiaoju 6d94d9c85a docs: fix hash format to 13-char Crockford Base32 (XXH64) 2026-05-18 07:03:02 +00:00
xiaoju 49a4d08c04 docs: add thread list --all and thread kill 2026-05-18 06:59:47 +00:00
xiaoju d5773369af docs: uwf thread subcommands, simplify start output 2026-05-18 06:58:35 +00:00
xiaoju f49e014f41 docs: update CLI design — uwf naming, simplify commands and agent protocol 2026-05-18 06:56:55 +00:00
xiaoju ab48a8169d docs: add stateless workflow CLI design
Refs #297
2026-05-18 06:37:25 +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
xingyue 0207f93303 fix: npm test → bun test per review 2026-05-13 17:42:11 +08:00
xingyue e1423f196b refactor: delegate publish to publish-all.sh, remove duplicated discovery+topo logic
- Remove inline auto-discover + Kahn's topo sort from publish.sh (was duplicating publish-all.sh)
- Remove inline publish loop + smoke test (publish-all.sh handles both)
- publish.sh now: bump version → replace workspace:* → build → test → call publish-all.sh → restore → commit
- Net: -97 lines, single source of truth for package discovery and publish order
2026-05-13 17:32:08 +08:00
xingyue ae6954a02f fix(publish): auto-discover packages + pre-publish test gate
What: Replace hardcoded PUBLISH_ORDER with auto-discovery of all
non-private packages, sorted by topological dependency order (Kahn's).
Add a test gate (npm test) after build, before publish.

Why: The manual list was missing workflow-gateway and workflow-agent-react,
causing them to never get published. Any future package additions would
have the same problem.

Changes:
- scripts/publish.sh: Replace static PUBLISH_ORDER array with node script
  that reads all packages/*/package.json, filters out private, and
  topologically sorts by @uncaged/* internal dependencies
- scripts/publish.sh: Add npm test step between build and publish,
  aborting on failure
2026-05-13 17:22:50 +08:00
xingyue aede8f7613 chore: publish v0.3.21
小橘 <xiaoju@shazhou.work>
2026-05-13 17:10:39 +08:00
xiaomo 6d1e0498ba Merge pull request 'refactor(dashboard): replace ELK with custom spine layout' (#235) from refactor/dashboard-custom-spine-layout into main 2026-05-13 09:03:34 +00:00
xingyue 6cce5e2593 chore: publish v0.3.20
小橘 <xiaoju@shazhou.work>
2026-05-13 17:00:43 +08:00
xingyue d3a7ed9062 chore: publish v0.3.19
小橘 <xiaoju@shazhou.work>
2026-05-13 16:56:55 +08:00
xingyue e7f733c393 refactor(dashboard): replace ELK with custom spine layout
What: Replace ELK layout engine with a hand-written spine layout that
topologically sorts nodes into a vertical main path with feedback edges
routed to the right side.

Why: ELK's layered algorithm spreads the graph too wide when handling
feedback (back) edges, causing fitView to shrink nodes until text is
unreadable. Our workflow graphs are predominantly linear pipelines with
feedback loops — a custom layout handles this topology much better.

Changes:
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  rewrite from async ELK to synchronous spine layout — topo-sort extracts
  main path, nodes stack vertically, feedback edges get right-side routing
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  add custom SVG path for feedback edges (right-side arc with Q curves),
  use typed isFeedback/isSelfLoop fields from ConditionEdgeData
- packages/workflow-dashboard/src/components/workflow-graph/types.ts:
  rename elkLabelX/Y to labelX/Y, add isFeedback and isSelfLoop fields
- packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx:
  remove ReactFlowProvider/useReactFlow/useEffect fitView workaround
  (no longer needed — layout is synchronous), simplify component
- packages/workflow-dashboard/package.json: remove elkjs and dagre deps
2026-05-13 16:54:04 +08:00
xingyue d4bb4a9324 Merge pull request 'fix(cli): point bin to dist/cli.js instead of src/cli.ts' (#234) from fix/cli-bin-path into main 2026-05-13 08:43:41 +00:00
xingyue e4900b6fd6 fix(cli): keep bin pointing to src/cli.ts, add src to files
The actual issue was that 'files' only included dist/, so src/ was
excluded from the published package. bun can run .ts natively — no
need to point bin at compiled dist/cli.js.

Fix: add 'src' to files array so it ships with the package.
2026-05-13 16:43:07 +08:00
xiaomo 39540d9ae8 Merge pull request 'fix(dashboard): address ELK layout review feedback' (#233) from fix/dashboard-elk-review-feedback into main 2026-05-13 08:40:32 +00:00
xingyue 10899364d4 fix(cli): point bin to dist/cli.js instead of src/cli.ts
The bin entry pointed to src/cli.ts but only dist/ is published,
causing 'Cannot find module cli-dispatch.js' on global install.
2026-05-13 16:38:54 +08:00
xingyue dc5fdd7358 fix(dashboard): address ELK layout review feedback
What: Fix three non-blocking issues from PR #232 review.

Why: Code quality — unhandled promise rejection risk, type safety,
and project convention compliance.

Changes:
- packages/workflow-dashboard/src/components/workflow-graph/types.ts:
  add elkLabelX/elkLabelY fields to ConditionEdgeData type (number | null,
  not optional — per project no-optional-properties rule)
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  remove 'as ConditionEdgeData' type assertion (now unnecessary),
  add .catch() to computeLayout promise
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  remove redundant inline type extension, use ConditionEdgeData directly

Ref: PR #232 review comments
2026-05-13 16:37:07 +08:00
xiaoju bb1293f6b9 fix: add exports field to 6 packages for proper type resolution
Packages without exports.types pointed main/types to src/ which
doesn't exist in published tarballs. Now all packages have:
  exports."." = { types: dist/index.d.ts, import: src/index.ts }

Bump to 0.3.18.
2026-05-13 08:29:36 +00:00
xiaomo 55b3b61498 Merge pull request 'feat(dashboard): switch graph layout from Dagre to ELK' (#232) from feat/dashboard-elk-layout into main 2026-05-13 08:28:24 +00:00
xingyue 484ed520cd feat(dashboard): switch graph layout from Dagre to ELK
What: Replace Dagre layout engine with ELK (Eclipse Layout Kernel) for
workflow graph visualization in the dashboard.

Why: Dagre lacks support for edge label placement and orthogonal edge
routing, causing condition labels to overlap with nodes. ELK provides
proper label positioning, better edge routing, and more compact layouts.

Changes:
- packages/workflow-dashboard/package.json: add elkjs dependency
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  rewrite layout from Dagre to async ELK with layered algorithm,
  orthogonal routing, reduced spacing for compactness
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  use ELK-computed label positions, show all labels including FALLBACK,
  switch to getSmoothStepPath for all edges
- packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx:
  wrap in ReactFlowProvider, add fitView on async layout change,
  key-based remount for layout stability
- packages/workflow-dashboard/src/components/workflow-list.tsx:
  left-right layout (info left, graph right), fix toggleExpanded
  React 18 batching bug, increase graph container height
2026-05-13 16:26:03 +08:00
xiaoju 497f03c747 chore: bump all packages to 0.3.17 2026-05-13 08:04:32 +00:00
xiaoju cfe4543d39 refactor!: remove deprecated Agent types, introduce Adapter-first API
BREAKING CHANGES:
- Remove AgentFn, AgentFnResult, AgentBinding from workflow-protocol
- Remove wrapAgentAsAdapter from workflow-util-agent
- workflowAsAgent → workflowAdapter (old name kept as deprecated re-export)

New APIs:
- createTextAdapter(producer) — bridges text-producing functions to AdapterFn
- TextProducerFn, TextAdapterResult types
- workflowAdapter() — direct AdapterFn for child workflow delegation

All agent packages (cursor, hermes, llm) now return AdapterFn directly,
no wrapping needed. Bundle entries simplified accordingly.

小橘 🍊(NEKO Team)
2026-05-13 08:03:27 +00:00
xiaoju 399b967c59 refactor: reduce cognitive complexity in dispatch.ts and shell-exec.ts
- Extract helpers from promptSecret/onData (32→~4)
- Extract sub-functions from collectInteractiveSetup (36→~8)
- Extract classifyExecError from shell-exec handler (17→~3)
- Replace all non-null assertions with safe .at() access

0 biome errors, 0 warnings.
2026-05-13 07:37:47 +00:00
xiaoju 061926b86a chore: fix all biome lint errors
- Auto-fix string concatenation → template literals
- Remove unused imports
- Prefix unused function with underscore
- Format fixes across multiple files
2026-05-13 07:26:11 +00:00
xiaoju acb0ebed97 chore: add @types/node for node:* module declarations 2026-05-13 07:21:43 +00:00
xiaoju d5d7be6100 chore: add files field to all packages, bump to 0.3.16
Excludes tsconfig.json and source files from published packages.
Fixes TypeScript errors when consuming packages via bun.
2026-05-13 07:19:49 +00:00
xiaoju 1566a43395 chore: bump all packages to 0.3.15 2026-05-13 07:04:12 +00:00
xiaoju afbde4573a chore: add bunfig.toml to gitignore (contains registry token) 2026-05-13 06:55:16 +00:00
xiaoju 63e447fc3d chore: unify npm registry to uncaged org
publish-all.sh now targets the same org as .npmrc.

小橘 🍊
2026-05-13 06:49:30 +00:00
xiaoju 34fcbf29cb chore: bump workflow-util and workflow-util-agent to 0.3.14
小橘 🍊
2026-05-13 06:12:29 +00:00
xiaoju 256799fcfd chore: bump workflow-util and workflow-util-agent to 0.3.12
小橘 🍊
2026-05-13 06:04:53 +00:00
xiaoju 21cf3db111 feat(util): extract requireEnv/optionalEnv to workflow-util
- requireEnv(name, message) — throws with custom error message
- optionalEnv(name, fallback?) — returns fallback or null
- Update develop and solve-issue bundle entries to use shared helpers
- Remove inline requireEnv/optionalEnv and wrapAgentAsAdapter usage
- Add tests for both functions

小橘 🍊
2026-05-13 06:02:17 +00:00
xiaomo ed38543db4 Merge pull request 'docs(skill): add authoring pitfalls to skill author topic' (#231) from fix/skill-author-pitfalls into main 2026-05-13 03:59:50 +00:00
xiaomo 78771fbebc Merge pull request 'fix(publish-all): regenerate lockfile before pack' (#230) from fix/publish-lockfile-regen into main 2026-05-13 03:59:42 +00:00
xiaoju c15f58bdeb docs(skill): add authoring pitfalls to skill author topic
Add ModeratorTable syntax, AdapterFn/AdapterBinding types, lazy init
pattern, bundle import restrictions, and descriptor requirements.

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

小橘 <xiaoju@shazhou.work>
2026-05-13 03:57:49 +00:00
xiaoju 6d4bf108bb fix(publish-all): regenerate lockfile before pack
After bumping versions, bun pm pack reads the old bun.lock and resolves
workspace:* to stale versions. Now deletes bun.lock and runs bun install
before the pack loop to ensure correct resolution.

小橘 <xiaoju@shazhou.work>
2026-05-13 03:52:10 +00:00
xingyue 5b7c9b844b fix(engine): abort signal races gen.next() to fix flaky kill test (#209)
Root cause: executeThread awaited gen.next() without racing against
the abort signal. When a workflow bundle awaited a long setTimeout
between yields, the engine could not respond to kill until the
Promise resolved — causing the kill test to flake when the thread
completed before kill arrived.

Fix: Promise.race gen.next() with an abort listener so kill takes
effect immediately, even mid-yield. Also move the bundle's delay
to after the first yield (between planner and coder) to ensure the
thread is killable while running.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #197, #194

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

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

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

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

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

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

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

小橘 <xiaoju@shazhou.work>
2026-05-12 00:58:37 +00:00
xiaomo e979a55f8a Merge pull request 'feat: cursor agent auto-extracts workspace from context' (#193) from feat/cursor-agent-workspace-extract into main 2026-05-12 00:57:33 +00:00
xiaoju 30f1582046 fix: address review feedback on cursor agent PR
1. Replace manual OpenAI response parsing with createThreadReactor —
   workspace extraction now uses the same ReAct loop as extract/summarizer,
   with a zod schema and structured tool call
2. Remove non-null assertion on llmProvider, replaced with explicit guard
3. Add structured logging (LogFn) to extraction — failures, non-absolute
   paths, and successful extractions all logged with tag V3KM8QWP
4. export const run = wf is correct: createWorkflow returns WorkflowFn
   directly, old bundle-entry had stale .run access + unused 3rd arg

小橘 <xiaoju@shazhou.work>
2026-05-11 14:05:01 +00:00
xiaoju cf0540d7fa fix: stabilize kill-thread test by polling instead of fixed delay
Replace 50ms setTimeout with waitUntilPredicate polling for first role
completion before issuing kill. Same pattern used by pause/resume test.

小橘 <xiaoju@shazhou.work>
2026-05-11 13:56:14 +00:00
xiaoju c05fac746c feat: cursor agent auto-extracts workspace from context via LLM
- workflow-agent-cursor: workspace config now optional (string | null)
- When null, uses LLM call to extract workspace path from AgentContext
  (previous steps' meta, start prompt) before spawning cursor-agent CLI
- Requires llmProvider when workspace is null
- develop bundle-entry: switched from hermes to cursor agent for all roles
- solve-issue bundle-entry: created, developer role uses workflowAsAgent('develop'),
  preparer/submitter remain hermes

小橘 <xiaoju@shazhou.work>
2026-05-11 13:51:41 +00:00
xiaoju 34efd25e91 chore: split release into publish.sh + deploy.sh
- publish.sh: version bump → workspace:* swap → npm publish → restore → commit
- deploy.sh: build + deploy dashboard/gateway to Cloudflare (supports single target)

小橘 <xiaoju@shazhou.work>
2026-05-11 12:22:06 +00:00
xiaoju cc0bc6c8aa chore: add release.sh script
Automates: version bump → workspace:* replace → npm publish (topo order)
→ restore workspace:* → dashboard build+deploy → git commit+push.

Env: GITEA_TOKEN, CLOUDFLARE_API_TOKEN (from cfg).

小橘 <xiaoju@shazhou.work>
2026-05-11 12:21:07 +00:00
xiaoju 626cb5d98e Merge pull request 'fix: sort thread list newest-first and differentiate status colors' (#192) from fix/191-dashboard-thread-sort into main 2026-05-11 12:16:19 +00:00
xiaoju f87cb38a67 fix: review — stable sort fallback, cleaner status colors
- Sort: threads without startedAt pushed to bottom (not random)
- Colors: completed=success(green), running/active=accent(blue), failed=error(red)
- Remove opacity hack, simplify ternary

小橘 <xiaoju@shazhou.work>
2026-05-11 12:14:51 +00:00
xiaoju 0970139418 fix: sort thread list newest-first and differentiate status colors
- Sort threads by startedAt descending (newest first)
- completed: green (dimmed) — distinct from running (green, full opacity)
- active: accent color (blue) — was same muted gray as completed
- failed: red — unchanged
- running: green — unchanged

Fixes #191
2026-05-11 12:09:48 +00:00
xiaoju 376dd87b6b chore: bump to v0.3.1, fix workspace:* in published packages
v0.3.0 was published with workspace:* deps (npm doesn't resolve them).
Re-published as v0.3.1 with concrete version refs.
Local monorepo deps restored to workspace:*.

小橘 <xiaoju@shazhou.work>
2026-05-11 11:00:49 +00:00
xiaoju 4d8469a649 chore: bump all packages to v0.3.0
Published to Gitea npm registry (git.shazhou.work).

小橘 <xiaoju@shazhou.work>
2026-05-11 10:52:35 +00:00
xiaoju a929fa4ccb Merge pull request 'feat: generate LLM summary in __end__ node via ReAct loop' (#190) from feat/187-end-node-llm-summary into main 2026-05-11 10:31:24 +00:00
xiaoju ff3e19fd22 docs: add comments explaining summarizer constants
小橘 <xiaoju@shazhou.work>
2026-05-11 10:28:54 +00:00
xiaoju b509d1715e refactor: extract shared CAS reactor pattern into cas-reactor.ts
Deduplicate CAS_GET_TOOL_DEFINITION, isRecord, toolHandler, and
structuredToolFromSchema between summarizer.ts and extract-fn.ts.

Both now use createCasReactor(provider, cas, opts) and only provide
their own systemPrompt.

小橘 <xiaoju@shazhou.work>
2026-05-11 10:25:31 +00:00
xiaoju b93f6e736f feat: generate LLM summary in __end__ node via ReAct loop
Instead of hardcoding 'completed: moderator returned END', the engine now
calls a summarizer (ReAct loop with cas_get tool) to produce a meaningful
summary of the workflow outcome before writing the __end__ node.

- New summarizer.ts following supervisor.ts pattern
- Uses extract scene LLM provider (falls back to raw completion.summary on failure)
- Tracks step contentHashes for summarizer context
- Schema: z.object({ summary: z.string() })

Refs #187, #188

小橘 <xiaoju@shazhou.work>
2026-05-11 10:11:31 +00:00
xiaoju ec13c19505 Merge pull request 'refactor: replace maxRounds with supervisor check interval' (#186) from refactor/185-remove-max-rounds into main 2026-05-11 09:01:24 +00:00
xiaoju 203b86e827 refactor: remove dead hasRoundsRemaining condition, use FALLBACK
The condition always returned true, making the subsequent FALLBACK → END
unreachable. Simplified to FALLBACK → coder directly.

Refs #185
2026-05-11 08:59:14 +00:00
xiaoju 90de1c7025 test: fix develop moderator tests for supervisor-controlled termination
hasRoundsRemaining is now always true — supervisor controls when to
stop, not a round counter. Tests updated to expect coder retry
instead of END on exhaustion.

Refs #185
2026-05-11 08:55:49 +00:00
xiaoju 2b587612d5 refactor: replace maxRounds with supervisor check interval
Removes maxRounds as a hard stop limit from the entire stack. The supervisor
(already configured via workflow.yaml supervisorInterval) is now the sole
termination authority.

Changes across 27 files in 11 packages:
- workflow-protocol: StartStep.meta is now empty, StartNodePayload drops maxRounds
- workflow-cas: isStartPayload no longer checks maxRounds
- workflow-execute: engine, worker, fork-thread all stripped of maxRounds plumbing
- cli-workflow: --max-rounds flag removed from CLI and HTTP API
- workflow-runtime: build-context and create-workflow no longer reference maxRounds
- workflow-dashboard: UI no longer sends maxRounds
- workflow-template-develop/solve-issue: moderator no longer checks rounds remaining
- All tests updated

Fixes #185
2026-05-11 08:51:35 +00:00
xingyue 2342a6e3bd fix: login.tsx use new gateway endpoint path 2026-05-11 16:46:38 +08:00
xingyue 0021596ff0 Merge pull request 'refactor: simplify ExtractFn to (schema, contentHash)' (#184) from refactor/180-simplify-extract-fn into main 2026-05-11 08:03:48 +00:00
xiaomo 56ec8cd401 Merge pull request 'refactor: reorganize gateway routes under /api/ prefix' (#183) from feat/177-gateway-route-reorg into main 2026-05-11 08:01:02 +00:00
xiaoju fe87efd79d fix: cursor agent workspace from config instead of type assertion
Address review feedback: remove unsafe `as unknown as` cast on
currentRole. CursorAgentConfig now takes workspace directly instead
of using ExtractFn to infer it from thread context.

Refs #180
2026-05-11 08:00:51 +00:00
xingyue b783027406 fix: remove stray lockfiles + refactor gateway auth with Hono group
- Remove root and workflow-gateway pnpm-lock.yaml (workspace mode)
- Replace startsWith auth skip with Hono route group
- Gateway management routes use GATEWAY_SECRET (per-route)
- /api/gateway/endpoints + /api/agents/* use dashboard auth
- No more global /api/* middleware with path-based exceptions
2026-05-11 15:59:42 +08:00
xiaoju 904ee1eb83 refactor: workflow-as-agent outputs readable summary instead of raw hash
Extract can now work with the readable content directly, without
needing a special extractPrompt for DAG traversal.

Closes #182
2026-05-11 07:55:49 +00:00
xiaoju 1742ced6df refactor: simplify ExtractFn to (schema, contentHash)
- Remove extractPrompt from RoleDefinition
- Remove ExtractContext type
- ExtractFn now takes (schema, contentHash) instead of (schema, prompt, ExtractContext)
- createExtract reads CAS content by hash, keeps ReAct loop with cas_get
- Coder schema uses .describe() for phase hash hint
- All role definitions, CLI templates, and skill output updated

Refs #180, closes #174, closes #181
2026-05-11 07:54:09 +00:00
xingyue 93145cf08c refactor: reorganize gateway routes under /api/ prefix (closes #178, closes #179)
- Gateway management: /api/gateway/register, /api/gateway/endpoints
- Agent proxy: /api/agents/:agent/*
- /healthz stays at root (CF/k8s convention)
- Skip dashboard auth for gateway register routes
- Update CLI serve tunnel registration paths
- Update dashboard API client paths

Ref: #177
2026-05-11 15:48:13 +08:00
xiaoju da6bcb10d6 feat(workflow): add declarative ModeratorTable type and migrate templates
Migrate workflow-template-develop and workflow-template-solve-issue
moderators to use the declarative ModeratorTable/tableToModerator
pattern. Update workflow-runtime re-exports and workflow-execute
engine to use renamed types.

Fixes #172
2026-05-11 06:25:39 +00:00
xiaoju 6fc97fc8c8 feat(workflow-protocol): add declarative moderator table types and tableToModerator
Add ModeratorCondition, FALLBACK, ModeratorTransition, ModeratorTable types
and tableToModerator converter function. Export from workflow-protocol and
re-export from workflow-runtime for backward compat.

Refs #172
2026-05-11 06:22:24 +00:00
xiaoju 93d9821f64 docs: update CLI skill with serve command, thread status, defaults, env vars
小橘 <xiaoju@shazhou.work>
2026-05-10 01:57:42 +00:00
xiaoju 29367cbe31 chore: remove stray bundle artifacts from repo
小橘 <xiaoju@shazhou.work>
2026-05-10 01:44:40 +00:00
xiaoju ec397aecd3 chore: remove stray bundle artifacts from repo
小橘 <xiaoju@shazhou.work>
2026-05-10 01:42:18 +00:00
xiaoju 2e9d939f8e fix: thread detail API returns correct status instead of source
小橘 <xiaoju@shazhou.work>
2026-05-10 01:39:09 +00:00
xiaoju 064a24f093 fix: no-ctl threads should be failed, not active
小橘 <xiaoju@shazhou.work>
2026-05-10 01:36:14 +00:00
xiaoju fede623a82 dashboard: remove 'All agents' dropdown option, auto-select first agent
小橘 <xiaoju@shazhou.work>
2026-05-09 13:26:11 +00:00
xiaoju 2a52b930b9 chore: raise default maxRounds from 5 to 10 (CLI, matches API default)
小橘 <xiaoju@shazhou.work>
2026-05-09 13:17:57 +00:00
xiaoju bf2f790e6e fix: detect crashed threads even when .running marker is already gone
Check worker PID liveness as final fallback — if worker is dead
and thread has no __end__ node, it crashed.

小橘 <xiaoju@shazhou.work>
2026-05-09 12:52:39 +00:00
xiaoju 08a79b77db fix: SSE sends 'done' event for non-running threads, frontend stops reconnecting
- routes-live: emit 'done' event before closing SSE for non-running threads
- use-sse: handle 'done' event — set completed, disconnect, stop reconnect
- Prevents 'Live' badge flash on failed/completed threads

小橘 <xiaoju@shazhou.work>
2026-05-09 12:49:20 +00:00
xiaoju 22a6200b69 fix: close SSE stream for non-running threads, fix Live badge
- routes-live: check .running marker before keeping SSE open;
  if thread is not running, emit existing records and close
- thread-detail: only show Live badge when connected AND not completed

小橘 <xiaoju@shazhou.work>
2026-05-09 12:45:58 +00:00
xiaoju 7e7f6aa6d6 fix: detect crashed threads by checking worker PID liveness
When .running marker exists but no __end__ in CAS chain,
check if the worker process is actually alive. Dead PID
means the worker crashed without cleanup → status 'failed'.

Fixes #170

小橘 <xiaoju@shazhou.work>
2026-05-09 12:38:18 +00:00
xiaoju d6fe3f844c fix: detect crashed threads as failed instead of stuck running
- resolveThreadListStatus() checks CAS chain for __end__ node
- Stale .running markers no longer cause false 'running' status
- Distinguish 'failed' (returnCode != 0) from 'completed'
- Worker signal handlers (SIGINT/SIGTERM) clean up .running files
- listRunningThreads filters out terminated threads with stale markers

Fixes #170

小橘 <xiaoju@shazhou.work>
2026-05-09 12:28:33 +00:00
xiaoju d0803019b5 feat: ephemeral agent token for serve ↔ gateway auth
- serve generates random UUID on startup
- registration sends agentToken to gateway, stored in KV
- gateway injects X-Agent-Token header when proxying to agent
- serve rejects /api/* requests without valid token
- healthz remains unauthenticated
- tunnel URL is now protected — direct access returns 401

小橘 <xiaoju@shazhou.work>
2026-05-09 12:05:10 +00:00
xiaoju f16e7641fd chore: add .env.production for dashboard gateway URL
小橘 <xiaoju@shazhou.work>
2026-05-09 11:58:51 +00:00
xiaoju 3b41625001 feat: dashboard API key authentication
- Gateway: DASHBOARD_API_KEY middleware on /endpoints and /api/* routes
- Dashboard: login page with key validation, stored in localStorage
- SSE: key passed as ?key= query param (EventSource can't set headers)
- Sidebar: logout button to clear key

Refs #169
小橘 <xiaoju@shazhou.work>
2026-05-09 11:56:25 +00:00
xiaoju c602d2284b fix(dashboard): pass content as children to ReactMarkdown
Self-closing <ReactMarkdown /> renders nothing — need children.

小橘 <xiaoju@shazhou.work>
2026-05-09 10:58:33 +00:00
xiaoju d96e10b0fc feat(dashboard): structured record rendering with markdown support (#169)
- API returns structured fields for thread-start (workflow, prompt, status)
  and workflow-result (returnCode, content, timestamp)
- New RecordCard component renders by type:
  - StartCard: workflow name badge + prompt blockquote
  - RoleMessage: role-colored badges (preparer/agent/extractor) + markdown
  - ResultCard: success/fail status badge + summary
- Added react-markdown + shiki for markdown rendering with syntax highlighting
- Replaces generic <pre> blocks with proper structured rendering

Refs #169
小橘 <xiaoju@shazhou.work>
2026-05-09 10:41:13 +00:00
xiaoju 8e36d3e1f5 fix: use getContentMerklePayload to extract prompt text
Was showing raw YAML of the CAS node instead of the payload string.

小橘 <xiaoju@shazhou.work>
2026-05-09 10:34:43 +00:00
xiaoju bbe4fe0ed1 fix: include prompt text in thread-start record
Read prompt from StartNode refs[0] CAS blob and display it.

小橘 <xiaoju@shazhou.work>
2026-05-09 10:32:59 +00:00
xiaoju e105c5cac1 fix: show workflow name instead of bundle hash in thread-start record
小橘 <xiaoju@shazhou.work>
2026-05-09 10:31:08 +00:00
xiaoju 578776fccf fix: add standard fields to thread-start record
小橘 <xiaoju@shazhou.work>
2026-05-09 10:27:03 +00:00
xiaoju cb756a999a fix: normalize workflow-result records to match ThreadRecord shape
Both REST and SSE endpoints now return workflow-result with standard
fields (role, content, timestamp) instead of non-standard (summary).
Fixes 'Invalid Date' and empty content in dashboard.

小橘 <xiaoju@shazhou.work>
2026-05-09 10:24:48 +00:00
xiaoju e0577ceefe fix: add /api/healthz alias for gateway proxy health check
Gateway proxies /api/neko/healthz → /api/healthz on the agent,
but healthz was only on /healthz. Dashboard status bar showed
permanent Offline.

小橘 🍊(NEKO Team)
2026-05-09 10:05:46 +00:00
xiaoju 024dd8c1e8 Merge pull request 'feat: auto-tunnel + CF Worker gateway + dashboard multi-agent' (#168) from feat/164-cf-worker-gateway into main 2026-05-09 10:02:36 +00:00
xiaoju 9e98119145 feat: dashboard multi-agent support + CF Pages deploy
Phase C of #164:
- Dashboard fetches agents from gateway /endpoints
- Sidebar shows agent selector with online/offline status
- All API calls routed through gateway /api/:agent/*
- Hash routing: #agent/threads/id format
- SSE live streaming via gateway proxy
- VITE_GATEWAY_URL env var for gateway configuration
- Deployed to CF Pages: workflow-dashboard-54r.pages.dev
- Custom domain: workflow.shazhou.work (pending SSL)

Ref: #164, closes #167

小橘 🍊(NEKO Team)
2026-05-09 10:01:27 +00:00
xiaoju fd8943f131 feat: serve auto-tunnel + gateway registration
Phase B of #164:
- serve --name <agent> starts cloudflared quick tunnel automatically
- Registers with CF Worker gateway, heartbeat every 60s
- Graceful unregister on SIGINT/SIGTERM
- --no-tunnel flag for local dev
- Default name from hostname

Ref: #164, closes #166

小橘 🍊(NEKO Team)
2026-05-09 09:53:08 +00:00
xiaoju f7253d5948 feat: CF Worker API gateway with KV endpoint registry
Phase A of #164:
- Hono-based CF Worker at workflow-gateway.shazhou.workers.dev
- POST /register — agent registration with shared secret
- DELETE /register/:name — unregister
- GET /endpoints — list online agents
- GET /api/:agent/* — proxy to agent tunnel URL
- KV-backed with TTL auto-expiry

Ref: #164, closes #165

小橘 🍊(NEKO Team)
2026-05-09 09:48:49 +00:00
xiaoju 1c5636c270 Merge pull request 'fix: content node refs field + backward compat' (#163) from fix/161-162-cas-content-refs into main 2026-05-09 09:10:09 +00:00
xiaoju ca0403c8ab fix: content node refs field + thread head update
Fixes #161

Fixes #162

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 08:53:02 +00:00
xiaoju aa25f55f63 fix: add workflow-protocol and workflow-util to bundle validator allowlist
小橘 <xiaoju@shazhou.work>
2026-05-09 08:36:39 +00:00
xiaoju e29d1bf345 feat: Phase 5 — CLI + Dashboard CAS adaptation, cleanup .data.jsonl
- Align REST API contracts for Dashboard (threads list, detail, SSE)
- Add content resolution from CAS in thread show + API responses
- Rename dataWatcher → threadsJsonWatcher in SSE routes
- Update docs (CLAUDE.md, architecture.md, skill.ts) to reflect CAS storage
- Zero .data.jsonl code paths in production code
- All 166 tests pass, bun run check clean

Refs #155, closes #160

小橘 <xiaoju@shazhou.work>
2026-05-09 08:16:04 +00:00
xiaoju f3aedf8d6c feat: Phase 4 — CAS-based fork + mark-and-sweep GC
- Rewrite fork to create StateNode pointing to fork point (zero duplication)
- Rewrite GC as mark-and-sweep: roots from threads.json + history, findReachableHashes via refs[]
- Remove .data.jsonl code paths
- Fix all 7 previously failing CLI tests
- New: gc-mark-sweep.test.ts verifying shared nodes survive GC
- All 166 tests pass

Refs #155, closes #159

小橘 <xiaoju@shazhou.work>
2026-05-09 08:12:49 +00:00
xiaoju 26cf51366f feat: Phase 3 — engine read path + runtime context builder
- Add buildThreadContext(headHash, cas) to workflow-runtime
- Expand extract phase to return { meta, contentPayload, refs[] }
- Add parseCasThreadNode() to workflow-cas for node type parsing
- Update createWorkflow to write ContentMerkleNode with artifact refs
- Tests: 4 pass (build-context + extract-refs)
- Biome format pass on all files

Refs #155, closes #158

小橘 <xiaoju@shazhou.work>
2026-05-09 08:00:24 +00:00
xiaoju 81c582ae0e feat: Phase 2 — engine write path (CAS nodes + threads.json)
- Engine writes StartNode, StateNode, ContentMerkleNode as CAS blobs
- threads.json tracks active threads, completed → history/{date}.jsonl
- No more .data.jsonl writes
- ancestors skip-list: [parent, ...parentAncestors] capped at 11
- Tests: 4 pass (engine write path)

Refs #155, closes #157

小橘 <xiaoju@shazhou.work>
2026-05-09 07:53:44 +00:00
xiaoju 6f000512d2 feat: Phase 1 — CAS thread storage types + helpers
- Add StartNode, StateNode, ContentMerkleNode types to workflow-protocol
- Add collectRefs() to workflow-cas — extracts CAS hashes from StateNode payload
- Add findReachableHashes() to workflow-cas — recursive mark traversal via refs[]
- Tests: 7 pass (collect-refs + reachable)

Refs #155, closes #156

小橘 <xiaoju@shazhou.work>
2026-05-09 07:30:47 +00:00
xiaoju 8f78a00063 docs: RFC v3 — named payload fields, refs as GC index, merge parent+ancestors
- payload is source of truth with named fields (start, content, ancestors, compact)
- refs[] auto-derived by collectRefs(), pure GC index
- parent merged into ancestors[0]

小橘 <xiaoju@shazhou.work>
2026-05-09 07:12:29 +00:00
xiaoju 6c2a137aef docs: update CAS thread storage RFC
- StartNode prompt via refs[0] instead of inline
- threads.json active-only, completed → history/{date}.jsonl
- Content Merkle node carries role artifact refs
- Extract phase expanded to produce refs[]

小橘 <xiaoju@shazhou.work>
2026-05-09 07:08:10 +00:00
xiaoju 6cd856ca99 docs: add RFC for CAS-based thread storage
小橘 <xiaoju@shazhou.work>
2026-05-09 06:34:24 +00:00
xiaoju 064696c558 docs: update architecture docs and package READMEs for post-split structure
- Rewrite docs/architecture.md with 15-package map, dependency graph, updated engine paths
- Update CLAUDE.md monorepo structure section
- Add READMEs for: workflow-protocol, workflow-runtime, workflow-util, workflow-cas, workflow-register, workflow-execute, workflow-reactor
- Fix agent READMEs: update deps from @uncaged/workflow to actual packages
- Mark workflow-as-agent plan as outdated

Fixes #153

小橘 <xiaoju@shazhou.work>
2026-05-09 04:39:57 +00:00
xiaoju 0f28e9b61a refactor: remove non-index re-exports
Remove re-exports from non-index files to enforce folder module discipline:
- workflow-agent-hermes: remove buildAgentPrompt re-export
- workflow-agent-cursor: remove buildAgentPrompt re-export
- cli-workflow/cli-dispatch: remove CommandEntry/CommandGroup/DispatchFn/getCommandRegistry re-exports

小橘 <xiaoju@shazhou.work>
2026-05-09 04:19:49 +00:00
xiaoju 1ea56009a2 Merge pull request 'chore: rename dashboard folder' (#152) from chore/rename-dashboard-folder into main 2026-05-09 03:59:32 +00:00
xiaoju 6cc2481a16 chore: remove accidental pnpm-lock.yaml 2026-05-09 03:58:30 +00:00
xiaoju 44018bd17d chore: rename packages/dashboard → packages/workflow-dashboard
Align folder name with package name @uncaged/workflow-dashboard,
consistent with all other packages in the monorepo.
2026-05-09 03:57:49 +00:00
xingyue 28c35bb3e0 Merge pull request 'refactor: 七包拆分 — protocol / runtime / util / cas / reactor / register / execute' (#151) from refactor/143-split-packages into main 2026-05-09 03:53:54 +00:00
xiaoju b8b557baf6 fix: migrate template test imports from @uncaged/workflow to new packages
小橘 🍊(NEKO Team)
2026-05-09 03:51:48 +00:00
xingyue 727b4bb3ed refactor(workflow): fix tsconfig references, template imports, delete old packages/workflow
- Update root tsconfig.json references: replace packages/workflow with 6 new packages
- Update cli-workflow tsconfig references to new packages
- Add tsconfig references to workflow-util, workflow-runtime, workflow-execute
- Fix workflow-agent-llm, workflow-agent-cursor, workflow-agent-hermes, workflow-util-agent
  tsconfig references (../workflow -> ../workflow-runtime)
- Remove stale @uncaged/workflow deps from agent package.json files
- Change template packages to import buildDescriptor from @uncaged/workflow-register
- Normalize package.json exports field across all new packages
- Delete old packages/workflow/ directory
2026-05-09 11:46:57 +08:00
xingyue 9bbdfc41bd feat(execute): create @uncaged/workflow-execute + CLI migration
Phase 7: Engine + extract + workflow-as-agent merged into execute package.
All CLI imports migrated from @uncaged/workflow to specific packages.
105 CLI tests pass, 0 failures.

Changes:
- New @uncaged/workflow-execute package (engine/, extract/, workflow-as-agent)
- CLI src/ and __tests__/ rewritten to import from split packages
- bundle-validator updated to allow @uncaged/workflow-cas imports
- ensure-uncaged-workflow-symlink creates symlinks for all new packages

Ref: #143, closes #150
2026-05-09 11:35:03 +08:00
xingyue b07f8cf166 feat(register): create @uncaged/workflow-register package
Merges bundle/ + registry/ + config/ modules. The config↔registry
circular dependency is resolved: ProviderConfig and WorkflowConfig
now come from @uncaged/workflow-protocol.

Ref: #143, closes #149
2026-05-09 11:16:27 +08:00
xingyue 1a1e8b3398 feat(cas,reactor): create @uncaged/workflow-cas and @uncaged/workflow-reactor
Phase 4: CAS module extracted with Merkle types, hash functions,
and fs-backed store. Imports CasStore type from protocol.

Phase 5: Reactor (ReAct loop) extracted as independent package.
Only depends on protocol — no cas or engine dependency.

Ref: #143, closes #147, closes #148
2026-05-09 11:11:33 +08:00
xingyue 39d2a61686 refactor(runtime): types down to @uncaged/workflow-protocol
All type definitions now originate from @uncaged/workflow-protocol.
Runtime re-exports them for backward compatibility. Local AdvanceOutcome
duplicate in create-workflow.ts removed (now imported from protocol).

Ref: #143, closes #146
2026-05-09 11:09:38 +08:00
xingyue bf0bc47a3f feat(util): create @uncaged/workflow-util package
Extract pure utility functions from workflow/src/util/ into standalone package.
Types (Result, ok, err) now come from @uncaged/workflow-protocol.

Contains: base32 encoding, ULID generation, structured logger,
storage-root helpers, refs-field normalization.

Ref: #143, closes #145
2026-05-09 11:08:04 +08:00
xingyue 2cffaad127 feat(protocol): create @uncaged/workflow-protocol package
Extract all cross-package type definitions and constructor functions
into a dedicated protocol layer. This is the foundation for the
seven-package split (RFC #143).

Contains:
- Result<T,E>, ok(), err()
- START, END constants
- CasStore, WorkflowFn, RoleOutput, WorkflowCompletion
- WorkflowDescriptor, WorkflowRoleDescriptor
- ProviderConfig, WorkflowConfig, ResolvedModel (fixes config↔registry cycle)
- RoleDefinition, Moderator, WorkflowDefinition
- AgentFn, ExtractFn, and all thread context types

Ref: #143, closes #144
2026-05-09 11:06:10 +08:00
xiaomo 9a3daac657 Merge pull request 'feat(workflow): ThreadReactor — generic ReAct loop + extract/supervisor migration' (#142) from feat/139-thread-reactor into main 2026-05-09 02:28:09 +00:00
xiaoju b8f9ffcb59 feat(workflow): migrate supervisor to ThreadReactor (Phase 2)
- Rewrite supervisor to use createThreadReactor + createLlmFn
- No direct fetch/HTTP calls in supervisor
- All 266 tests passing

Refs #139, relates #141
2026-05-09 02:26:39 +00:00
xiaoju a7171f05f6 feat(workflow): add ThreadReactor generic ReAct loop + migrate extract (Phase 1)
- New src/reactor/ module: createThreadReactor, createLlmFn, types
- Two-stage API: config (llm, systemPrompt, tools, toolHandler) + per-call (thread, input, schema)
- All tool failures are recoverable (returned to LLM as error message)
- Rewrite createExtract to use createThreadReactor
- Delete reactExtract old implementation
- Fix template test imports (START/END from runtime, validateWorkflowDescriptor from engine)

268 tests passing.

Refs #139, relates #140
2026-05-09 02:15:38 +00:00
xiaoju b53667a2aa Merge pull request 'refactor(workflow): move descriptor validation out of runtime' (#135) from refactor/runtime-descriptor-boundary into main 2026-05-08 15:05:24 +00:00
xiaomo 2c0e744ebf Merge pull request 'perf(serve): SSE live pump reads incrementally' (#137) from fix/130-sse-incremental into main 2026-05-08 15:00:44 +00:00
xiaomo ae16f09688 Merge pull request 'feat(dashboard): hash routing + health check polling' (#138) from fix/128-dashboard-enhancements into main 2026-05-08 15:00:34 +00:00
xiaomo 73a3638ad9 Merge pull request 'fix(serve): error handling, CORS, body limit, CAS store reuse' (#136) from fix/120-serve-hardening into main 2026-05-08 15:00:32 +00:00
xingyue 7b0260cedd feat(dashboard): hash routing + health check polling
- Hash-based URL routing (#threads, #threads/{id}, #workflows)
  for bookmarkable/shareable thread links
- Health check polls every 10s with reconnecting state
- useHashRoute hook for clean route management

Closes #128
2026-05-08 18:16:09 +08:00
xingyue 61fc1cfe1b perf(serve): SSE live pump reads incrementally instead of full file
Use Bun.file().slice() to read only new bytes from the last known
offset instead of re-reading the entire JSONL file on every fs watch.

- readNewBytes() helper with byte-offset tracking
- Handles file truncation (resets offset)
- Early return when no new data

Closes #130
2026-05-08 18:14:04 +08:00
xingyue 6b1e728700 fix(serve): error handling, CORS, body limit, CAS store reuse
- Global error handler (app.onError → 500 JSON)
- JSON parse validation on POST routes (400)
- CORS restricted to localhost origins
- 1MB body size limit on POST (413)
- CAS store created once per route group, not per-request
- 6 new tests covering all changes

Closes #120
2026-05-08 18:11:59 +08:00
xiaomo dedab62c49 Merge pull request 'feat(dashboard): connect thread detail to SSE live stream' (#134) from feat/131-dashboard-sse into main 2026-05-08 09:53:10 +00:00
xingyue a44f1f34a8 feat(dashboard): connect thread detail to SSE live stream
Add useSSE hook that connects to /api/threads/:id/live via EventSource.
Thread detail page now shows records in real-time with auto-scroll.

- useSSE hook: EventSource connection, record accumulation, auto-reconnect
  with exponential backoff, cleanup on unmount
- Thread detail: Live badge, SSE-first with fetch fallback, smooth scroll
- Records clear on reconnect (server replays full file)

Closes #131, testing verified per #133
Refs: #118
2026-05-08 17:52:10 +08:00
550 changed files with 32604 additions and 8802 deletions
+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)
+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!"
+7
View File
@@ -4,3 +4,10 @@ bun.lock
*.tgz
tsconfig.tsbuildinfo
.npmrc
bunfig.toml
xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
.DS_Store
*.py
+167
View File
@@ -0,0 +1,167 @@
name: "solve-issue"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
roles:
planner:
description: "Analyzes issue and outputs a TDD test spec"
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
capabilities:
- issue-analysis
- planning
procedure: |
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Read CLAUDE.md (or equivalent project conventions file) to understand coding standards
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output status=insufficient_info and terminate
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
1. Read the tester's output from the previous step to understand what's wrong with the spec
2. Revise the test spec accordingly
After producing the test spec:
1. Store it via `uwf cas put "<markdown content>"` and capture the returned hash
2. Put the hash in meta.plan (required when status=ready)
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
frontmatter:
type: object
properties:
status:
type: string
enum: [ready, insufficient_info]
plan:
type: string
required: [status]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
capabilities:
- coding
procedure: |
1. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's meta.plan)
2. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
3. Write tests first based on the spec
4. Implement the code to make tests pass
5. Ensure `bun run build` passes with no errors
6. Run `bun test` to verify all tests pass
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
frontmatter:
type: object
properties:
status:
type: string
enum: [done, failed]
required: [status]
reviewer:
description: "Code standards compliance check"
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
capabilities:
- code-review
- static-analysis
procedure: |
Hard checks (must all pass):
1. `bun run build` — no build errors
2. `bunx biome check` — no lint violations
3. TypeScript strict mode — no type errors
Soft checks (review against CLAUDE.md conventions):
- Functional-first: `function` + `type`, not `class` + `interface`
- No optional properties (`?:`) — use `T | null`
- Naming conventions (kebab-case files, PascalCase types, camelCase functions)
- Module boundary discipline (folder exports via index.ts)
- No `console.log` (use structured logger)
- No dynamic imports in production code
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
frontmatter:
type: object
properties:
approved:
type: boolean
required: [approved]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
capabilities:
- testing
procedure: |
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's meta.plan)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
frontmatter:
type: object
properties:
status:
type: string
enum: [passed, fix_code, fix_spec]
required: [status]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
frontmatter:
type: object
properties:
success:
type: boolean
required: [success]
conditions:
insufficientInfo:
description: "Planner determined there's not enough info to proceed"
expression: "$last('planner').status = 'insufficient_info'"
devFailed:
description: "Developer failed to implement"
expression: "$last('developer').status = 'failed'"
rejected:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
fixCode:
description: "Tester found code issues"
expression: "$last('tester').status = 'fix_code'"
fixSpec:
description: "Tester found spec issues"
expression: "$last('tester').status = 'fix_spec'"
hookFailed:
description: "Push hook failed"
expression: "$last('committer').success = false"
graph:
$START:
- role: "planner"
planner:
- role: "$END"
condition: "insufficientInfo"
- role: "developer"
developer:
- role: "$END"
condition: "devFailed"
- role: "reviewer"
reviewer:
- role: "developer"
condition: "rejected"
- role: "tester"
tester:
- role: "developer"
condition: "fixCode"
- role: "planner"
condition: "fixSpec"
- role: "committer"
committer:
- role: "developer"
condition: "hookFailed"
- role: "$END"
+88 -35
View File
@@ -2,32 +2,41 @@
## Project Overview
**@uncaged/workflow** is a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier.
This monorepo implements a stateless workflow engine driven by a single-step CLI (`uwf`). Workflows are **YAML definitions** stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
### Key Terms
| Concept | What it is |
|---------|-----------|
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, conditions, and a routing graph. Stored as a CAS node, identified by its XXH64 hash. |
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
| **Moderator** | JSONata-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
### Monorepo Structure
```
workflow/
packages/
workflow/ # @uncaged/workflow — core lib (types, hash, ULID, JSONL, registry)
cli-workflow/ # @uncaged/cli-workflow — CLI (uncaged-workflow command)
docs/ # RFCs, conventions
biome.json # root Biome config
tsconfig.json # root TypeScript config
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
workflow-moderator/ # @uncaged/workflow-moderator — JSONata graph evaluator
workflow-agent-kit/ # @uncaged/workflow-agent-kit — createAgent factory, context builder, extract pipeline
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
legacy-packages/ # Archived packages (preserved for reference, not active)
examples/ # Workflow YAML examples (solve-issue.yaml)
docs/ # Architecture docs
biome.json # root Biome config
tsconfig.json # root TypeScript config
```
- `workflow` is the core; `cli-workflow` depends on it
- Packages use `workspace:*` protocol
- Dependency layers: `workflow-protocol` → (`workflow-util`, `workflow-moderator`) → `workflow-agent-kit``workflow-agent-hermes` / `cli-workflow`
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
## Language & Paradigm
@@ -95,8 +104,6 @@ type WorkflowEntry = {
- Always named exports, never default exports
- One module = one responsibility, filename = purpose
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
### Folder Module Discipline
Every folder under `src/` is a **module boundary**. Four rules:
@@ -122,10 +129,10 @@ export { createCasStore } from "../cas/cas.js";
// ❌ Bad — types defined in index.ts
// in cas/index.ts:
export type CasStore = { ... }; // should be in cas/types.ts
export type CasStore = { ... }; // should be in cas/types.ts
```
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`) are not inside a folder module and follow normal rules.
## Naming
@@ -146,7 +153,7 @@ Workflow names use **verb-first** kebab-case:
### ID Encoding
All IDs use **Crockford Base32**:
- Bundle hash: XXH64 → 13-char Crockford Base32
- CAS hash: XXH64 → 13-char Crockford Base32
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
## Error Handling
@@ -167,15 +174,15 @@ type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
Never use `console.log/warn/error` directly — Biome's `noConsole` rule enforces this.
All logging goes through the structured logger from `@uncaged/workflow`:
All logging goes through the structured logger from `@uncaged/workflow-util`:
```typescript
import { createLogger } from "@uncaged/workflow";
import { createLogger } from "@uncaged/workflow-util";
const log = createLogger();
// Each call site has a fixed 8-char Crockford Base32 tag
log("4KNMR2PX", "Loading workflow bundle...");
log("4KNMR2PX", "Loading workflow...");
log("7BQST3VW", `Role ${role} started`);
```
@@ -190,7 +197,7 @@ log("7BQST3VW", `Role ${role} started`);
### Why fixed tags?
- `grep "4KNMR2PX"` in `.info.jsonl` → instant code location
- `grep "4KNMR2PX"` in logs → instant code location
- No need for file/line info in the log — tag is the locator
- Survives refactoring (tag stays the same when code moves)
@@ -207,36 +214,82 @@ console.log(result);
Do NOT use `await import()` in production code. Always use static top-level `import`.
**Exception**: The bundle loader and `extractBundleExports` dynamically import user workflow files at runtime.
```ts
// Dynamic import required: user bundle path resolved at runtime
const mod = await import(bundlePath);
```
Test files (`__tests__/**`) are exempt.
## Toolchain
| Tool | Purpose |
|------|---------|
| **bun** | Package manager + runtime + test runner |
| **bun** | Package manager + runtime |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format (replaces ESLint + Prettier) |
| **vitest** | Test runner (`cli-workflow` uses vitest; other packages use `bun test`) |
### Commands
### Development Workflow
```bash
bun run check # tsc --build + biome check
bun run format # biome format --write
bun test # run tests
# ── Setup ──
bun install # install all workspace dependencies
# ── Daily development ──
bun run build # tsc --build (all packages, dependency order)
bun run check # tsc --build + biome check + lint-log-tags
bun run format # biome format --write
bun test # run tests across all packages
# ── Before committing ──
bun run check # must pass — typecheck + lint + log tag validation
bun test # must pass — all package tests
```
### Publishing
All public `@uncaged/*` packages are published to **npmjs.org** with **fixed mode** (all packages share the same version number).
```bash
# 1. Add a changeset describing the change
bun changeset
# 2. Bump all package versions + generate CHANGELOGs
bun version
# 3. Build, test, and publish (runs scripts/publish-all.mjs)
bun release
# Or publish manually with a tag:
node scripts/publish-all.mjs --tag alpha
node scripts/publish-all.mjs --dry-run # preview without publishing
```
- `workspace:^` dependencies resolve to `^x.y.z` on publish
- Publish order defined in `scripts/publish-all.mjs` (dependency order)
- Changesets config: `.changeset/config.json` (fixed mode, public access)
### End-to-end: Author → Register → Run
```
examples/solve-issue.yaml — write a workflow YAML definition
│ uwf workflow put
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
│ uwf thread start <name> -p "..."
~/.uncaged/workflow/threads.yaml — new thread head pointer
│ uwf thread step <thread-id>
moderator → agent → extract — one step per invocation, repeat until $END
```
1. **Author** — write a workflow YAML file with roles, conditions, and graph
2. **Register**`uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
3. **Run**`uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
## Commit Convention
```
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: workflow | cli | rfc-001 | ...
scope: workflow | cli | moderator | agent-kit | hermes | util | protocol | ...
```
+69 -47
View File
@@ -1,71 +1,93 @@
# @uncaged/workflow
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, JSONata routing conditions, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
## Core Concepts
## Package Map
| Concept | Description |
|---------|-------------|
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
| Package | npm | Role |
|---------|-----|------|
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI binary — thread lifecycle, workflow registry, CAS inspection, setup |
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `WorkflowConfig`, etc.) |
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — determines next role or `$END` |
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, two-layer extract pipeline |
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` agent — spawns Hermes chat, captures session |
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing |
## Monorepo Packages
```
packages/
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
```
Managed with **bun workspace** using the `workspace:*` protocol.
External: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (CAS store + JSON Schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
## Quick Start
```bash
# Install dependencies
bun install
# 1. Configure provider and model
uwf setup
# Build all packages
bun run build
# 2. Register a workflow from YAML
uwf workflow put examples/solve-issue.yaml
# Register a workflow bundle
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
# 3. Start a thread
uwf thread start solve-issue -p "Fix the login redirect bug"
# Run a workflow
uncaged-workflow run solve-issue --prompt "Fix bug #42"
# 4. Execute steps (one at a time, until done)
uwf thread step <thread-id>
```
## CLI Usage
## CLI Commands
```bash
uncaged-workflow # Print full command usage (exits with status 1)
uncaged-workflow workflow list # List registered workflows
uncaged-workflow run <name> # Start a workflow thread
uncaged-workflow thread list # List all threads
uncaged-workflow thread show <id> # Inspect a thread
uncaged-workflow skill # Agent-consumable reference docs
```
### Thread
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
| Command | Description |
|---------|-------------|
| `uwf thread start <workflow> -p <prompt>` | Create a thread (no execution) |
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle |
| `uwf thread show <thread-id>` | Show head pointer and done status |
| `uwf thread list [--all]` | List threads (`--all` includes archived) |
| `uwf thread steps <thread-id>` | List all steps chronologically |
| `uwf thread read <thread-id> [--quota N]` | Render thread as readable markdown |
| `uwf thread fork <step-hash>` | Fork from a specific step |
| `uwf thread step-details <step-hash>` | Dump full detail node |
| `uwf thread kill <thread-id>` | Terminate and archive |
### Workflow
| Command | Description |
|---------|-------------|
| `uwf workflow put <file.yaml>` | Register a workflow from YAML |
| `uwf workflow show <name-or-hash>` | Show workflow definition |
| `uwf workflow list` | List registered workflows |
### CAS
| Command | Description |
|---------|-------------|
| `uwf cas get <hash>` | Read a CAS node |
| `uwf cas put <type-hash> <data>` | Store a node |
| `uwf cas has <hash>` | Check existence |
| `uwf cas refs <hash>` | List direct references |
| `uwf cas walk <hash>` | Recursive traversal |
| `uwf cas reindex` | Rebuild type index |
| `uwf cas schema list` | List schemas |
| `uwf cas schema get <hash>` | Show a schema |
### Setup
| Command | Description |
|---------|-------------|
| `uwf setup` | Interactive provider/model/agent configuration |
| `uwf setup --provider ... --base-url ... --api-key ... --model ...` | Non-interactive setup |
Config stored in `~/.uncaged/workflow/config.yaml`. API keys in `~/.uncaged/workflow/.env`.
## Development
```bash
bun run check # Biome lint + format check
bun run format # Auto-format with Biome
bun test # Run tests
bun install --no-cache # Install dependencies
bun run check # tsc + biome + lint-log-tags
bun run format # Auto-format with Biome
bun test # Run all tests
```
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
## Architecture
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
+21 -3
View File
@@ -1,7 +1,15 @@
{
"$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",
"!**/legacy-packages",
"!scripts",
"!packages/workflow/workflow",
"!xiaoju/scripts/bundle.ts"
]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": {
@@ -30,7 +38,7 @@
}
},
{
"includes": ["**/*.d.ts"],
"includes": ["**/*.d.ts", "**/vitest.config.*"],
"linter": {
"rules": {
"style": {
@@ -38,6 +46,16 @@
}
}
}
},
{
"includes": ["**/cli.ts", "**/setup.ts"],
"linter": {
"rules": {
"suspicious": {
"noConsole": "off"
}
}
}
}
],
"linter": {
-2
View File
@@ -1,2 +0,0 @@
[test]
pathIgnorePatterns = ["dist/**"]
+424 -185
View File
@@ -1,256 +1,495 @@
# @uncaged/workflow — Architecture
# Workflow Engine — Architecture
**Last updated:** 2026-05-06 by 小橘 🍊(NEKO Team)
**Last updated:** 2026-05-19
---
## Overview
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
## Package Structure
The implementation lives in **6** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
| Package | npm Name | Purpose |
|---------|----------|---------|
| `workflow` | `@uncaged/workflow` | Core: types, engine, ExtractFn, hash/ULID/registry |
| `cli-workflow` | `@uncaged/cli-workflow` | CLI: `uncaged-workflow` command |
| `workflow-agent-cursor` | `@uncaged/workflow-agent-cursor` | Cursor CLI agent (extracts workspace from ctx) |
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
| `workflow-template-develop` | `@uncaged/workflow-template-develop` | Develop workflow template (roles in `src/roles/`) |
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Solve-issue workflow template (roles in `src/roles/`) |
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
## Package map
Monorepo with **bun workspace**, `workspace:*` protocol.
| Layer | Package | One-line role |
|-------|---------|---------------|
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
| Shared infra | `@uncaged/workflow-util``workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
| Moderator | `@uncaged/workflow-moderator``workflow-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
| Agent framework | `@uncaged/workflow-agent-kit``workflow-agent-kit` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent: Hermes | `@uncaged/workflow-agent-hermes``workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
| CLI | `@uncaged/cli-workflow``cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
## Core Types
### External dependencies
```typescript
// --- Sentinel values ---
const START = "__start__";
const END = "__end__";
| Package | Role |
|---------|------|
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
| `jsonata` | JSONata expression evaluator (used by `workflow-moderator`). |
| `commander` | CLI argument parsing (used by `cli-workflow`). |
| `dotenv` | Loads `.env` files for API keys. |
| `yaml` | YAML parse/stringify. |
// --- RoleMeta: maps role names → their meta types ---
type RoleMeta = Record<string, Record<string, unknown>>;
## Dependency graph
// --- Role Definition: pure data, no execution logic ---
type RoleDefinition<Meta> = {
description: string; // human-readable
systemPrompt: string; // given to agent
extractPrompt: string; // given to extractor
schema: z.ZodType<Meta>; // meta shape (Zod v4)
};
// --- Workflow Definition: pure data, no agent binding ---
type WorkflowDefinition<M extends RoleMeta> = {
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>;
};
// --- Agent: raw string output, reads role info from context ---
type AgentFn = (ctx: AgentContext) => Promise<string>;
// --- Agent Binding: runtime assignment ---
type AgentBinding = {
agent: AgentFn;
overrides?: Partial<Record<string, AgentFn>>;
};
// --- Extract: structured data from context ---
type ExtractFn = <T>(schema: z.ZodType<T>, prompt: string, ctx: ExtractContext) => Promise<T>;
// --- Moderator: pure routing function ---
type Moderator<M extends RoleMeta> = (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
// --- Composition ---
// createWorkflow(def, binding, extract) => WorkflowFn
```mermaid
flowchart BT
subgraph External
jcas["@uncaged/json-cas"]
jcasfs["@uncaged/json-cas-fs"]
end
subgraph L0["Layer 0 — contract"]
protocol["@uncaged/workflow-protocol"]
end
subgraph L1["Layer 1 — shared"]
util["@uncaged/workflow-util"]
moderator["@uncaged/workflow-moderator"]
end
subgraph L2["Layer 2 — agent framework"]
kit["@uncaged/workflow-agent-kit"]
end
subgraph L3["Layer 3 — agent implementations"]
hermes["@uncaged/workflow-agent-hermes"]
end
subgraph L4["Layer 4 — CLI"]
cli["@uncaged/cli-workflow"]
end
protocol --> jcasfs
util --> protocol
moderator --> protocol
kit --> protocol
kit --> util
kit --> jcas
kit --> jcasfs
hermes --> kit
hermes --> jcas
cli --> protocol
cli --> util
cli --> kit
cli --> moderator
cli --> jcas
cli --> jcasfs
```
## Three-Phase Engine Loop
## Workflow definition
Each role execution has three distinct phases with progressive context:
Workflows are **YAML files** (not ESM bundles). `uwf workflow put <file.yaml>` parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the `WorkflowPayload` as a CAS node.
Example (`examples/solve-issue.yaml`):
```yaml
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
capabilities:
- issue-analysis
- planning
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
output: "Output the plan summary and list of concrete steps."
meta:
type: object
properties:
plan: { type: string }
steps: { type: array, items: { type: string } }
required: [plan, steps]
developer:
description: "Implements code changes"
goal: "You are a developer agent. Implement the plan."
capabilities:
- file-edit
- shell
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
output: "List all files changed and provide a summary of the implementation."
meta:
type: object
properties:
filesChanged: { type: array, items: { type: string } }
summary: { type: string }
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. Review the implementation."
capabilities:
- code-review
procedure: "Review the implementation against the plan."
output: "Approve or reject with detailed comments."
meta:
type: object
properties:
approved: { type: boolean }
comments: { type: string }
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
planner:
- role: "developer"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
Key properties:
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
## Three-phase engine loop
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli-workflow/src/commands/thread.ts` (`cmdThreadStep`).
```
┌─→ Phase 1: MODERATOR
Context: ModeratorContext { threadId, start, steps }
Action: moderator(ctx) → role name | END
Input: WorkflowPayload + ModeratorContext { start, steps[] }
Engine: JSONata conditions evaluated against the graph
│ Output: next role name | $END
│ Phase 2: AGENT
Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
Action: agent(ctx) → raw string
Input: thread-id + role (via argv)
Engine: agent-kit builds context from CAS chain, prepends
│ output format instruction to system prompt, spawns agent
│ Output: raw string (frontmatter markdown)
│ Phase 3: EXTRACTOR
Context: ExtractContext = AgentCtx + { agentContent }
Action: extract(schema, extractPrompt, ctx) → typed meta
│ Phase 3: EXTRACT
Input: raw agent output + role's meta schema
Engine: two-layer extract (frontmatter fast path → LLM fallback)
│ Output: CasRef to structured output node
Merge: RoleStep { role, content, meta, timestamp }
Append to steps
└─────────────────────────────────────────────────────┘
Persist: StepNode { start, prev, role, output, detail, agent }
Update: threads.yaml head pointer
└─────────────────────────────────────────────────────────────────
```
### Context Types (progressive)
### Context types
Defined in `packages/workflow-protocol/src/types.ts`:
```typescript
// Phase 1: Moderator sees accumulated state only
type ModeratorContext<M> = {
threadId: string;
start: StartStep;
steps: RoleStep<M>[];
type StepContext = {
role: string;
output: unknown; // CAS node payload, expanded (not hash)
detail: CasRef;
agent: string;
};
// Phase 2: Agent knows its identity
type AgentContext<M> = ModeratorContext<M> & {
currentRole: { name: string; systemPrompt: string };
type ModeratorContext = {
start: StartNodePayload; // { workflow: CasRef, prompt: string }
steps: StepContext[]; // chronological, oldest first
};
// Phase 3: Extractor has agent output
type ExtractContext<M> = AgentContext<M> & {
agentContent: string;
type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
};
// ThreadContext is an alias for AgentContext (backward compat)
type ThreadContext<M> = AgentContext<M>;
```
### Key Properties
### Key properties
- **Moderator is synchronous and pure** — no I/O, no state mutation
- **Agent gets context, not instructions** — reads `ctx.currentRole.systemPrompt`
- **Extractor is a general tool** — not limited to post-agent extraction; agents can use it too (e.g. Cursor agent extracts workspace path before execution)
- **extractPrompt is a call parameter**, not context state — different callers use different prompts
- **Moderator** — pure JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates `workflow.graph[currentRole]` transitions in order, returns first match.
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
## Agent Information Sources
## Agent CLI protocol
An agent has exactly three information sources:
Each agent is an external command invoked by `uwf thread step`:
1. **Prior knowledge** — LLM training, agent memory, agent skills
2. **Thread context**`AgentContext` (start, steps, currentRole)
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
No hidden environment parameters. If an agent needs something (like a workspace path), it extracts it from context using `ExtractFn`.
## Bundle Contract
A workflow bundle is a single `.esm.js` file with two named exports:
```typescript
// Named exports (no default export)
export const descriptor: WorkflowDescriptor;
export const run: WorkflowFn;
type WorkflowFn = (
input: { prompt: string; steps: RoleOutput[] },
options: { threadId: string; maxRounds: number },
) => AsyncGenerator<RoleOutput, WorkflowResult>;
```bash
<agent-cmd> <thread-id> <role>
```
### Constraints
Contract:
1. `uwf thread step` determines the next role via the moderator
2. Agent CLI is spawned with `(thread-id, role)` as positional args
3. `workflow-agent-kit` (`createAgent`) handles the boilerplate:
- Parses argv
- Loads `.env` from storage root
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
- Calls the agent's `run` function
- Runs two-layer extract on the raw output
- Writes `StepNode` to CAS (output + detail + prev link)
- Prints the new `StepNode` CAS hash to stdout
4. `uwf thread step` reads stdout, updates `threads.yaml` head pointer, re-evaluates moderator for `done`
5. Exit 0 = success, non-zero = failure
- Single `.esm.js` file
- No dynamic `import()`
- All static imports must be Node built-in modules only
- XXH64 hash (Crockford Base32) = globally unique version ID
Agent resolution priority: `--agent` CLI override → `config.yaml` per-workflow/role override → `config.yaml` `defaultAgent`.
### Why AsyncGenerator?
## Agent output format: frontmatter markdown (RFC #351)
- Each `yield` → engine writes to `.data.jsonl`, checks abort/pause
- `return` → engine marks thread complete
- Fork = pass historical steps as `input.steps` to a new generator
- Zero injection — bundle doesn't import from the engine
Agents produce **frontmatter markdown** — YAML frontmatter for structured meta, followed by a markdown body for content:
## Storage Layout
```markdown
---
status: done
next: reviewer
confidence: 0.9
artifacts:
- src/auth.ts
scope: role
---
## Implementation
Fixed the login redirect by updating the auth middleware...
```
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
## Two-layer extract
Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
### Layer 1: frontmatter fast path (`frontmatter.ts`)
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
2. Validate required fields (`validateFrontmatter`)
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
4. `store.put()` the candidate against the role's `meta` schema
5. Validate with `json-cas` schema validation
6. If valid → return `outputHash` (zero LLM cost)
### Layer 2: LLM extract fallback (`extract.ts`)
If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy schema):
1. Resolve extract model alias from config (`modelOverrides.extract``models.extract``defaultModel`)
2. Call OpenAI-compatible chat completion with JSON mode
3. System prompt: "Extract structured data matching this JSON Schema: ..."
4. User message: the raw agent output
5. Parse response, `store.put()`, validate
6. Return `outputHash`
## Prompt injection
`workflow-agent-kit` prepends two pieces of context to the agent's system prompt:
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
## CAS node types
### Workflow
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent..."
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
conditions:
notApproved:
description: "Reviewer rejected"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
```
### StartNode
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
prompt: "Fix the login bug..."
```
### StepNode
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against meta schema)
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
agent: "uwf-hermes" # agent command used (plain string)
```
### Chain structure
```
threads.yaml: { "01J7K9...4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → Workflow (CAS)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── prev ──→ StepNode (step 1)
│ │ └── prev: null
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(session turns)
└── agent: "uwf-hermes"
```
## Storage layout
```
~/.uncaged/workflow/
├── bundles/
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
│ └── C9NMV6V2TQT81.yaml # Role descriptor
├── logs/ # One folder per bundle hash
│ └── C9NMV6V2TQT81/
│ ├── 01KQXKW…YG.data.jsonl # Thread state
│ └── 01KQXKW…YG.info.jsonl # Debug log
└── workflow.yaml # Registry
├── cas/ # json-cas filesystem store (all CAS nodes)
├── config.yaml # Provider, model, agent configuration
├── threads.yaml # Active thread head pointers: threadId → CasRef
├── history.jsonl # Archived thread records
├── registry.yaml # Workflow name → CAS hash mapping
└── .env # API keys (loaded by dotenv)
```
### ID Encoding: Crockford Base32
### Mutable state
Only three files carry mutable state:
| File | Contents |
|------|----------|
| `threads.yaml` | `Record<ThreadId, CasRef>` — maps active thread IDs to head node hash |
| `history.jsonl` | Append-only log of completed threads (`thread`, `workflow`, `head`, `completedAt`) |
| `registry.yaml` | Workflow name → current CAS hash |
Everything else is immutable CAS content.
### ID encoding: Crockford Base32
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
- Bundle hash: XXH64 → 13-char
- Thread ID: ULID → 26-char (10 timestamp + 16 random)
- CAS hash: XXH64 → 13-char Crockford Base32
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
### Registry (`workflow.yaml`)
### Config (`config.yaml`)
```yaml
workflows:
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
hash: "C9NMV6V2TQT81"
timestamp: 1714963200000
history:
- hash: "A7BKR3M1NPQ40"
timestamp: 1714876800000
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
```
### Thread JSONL
## CLI commands
**`.data.jsonl`** — Line 1: start record, Line 2+: role outputs
Binary: `uwf`
```jsonc
// Start record
{ "name": "solve-issue", "hash": "C9NMV6V2TQT81", "threadId": "01KQXKW…",
"parameters": { "prompt": "Fix bug #3", "options": { "maxRounds": 5 } },
"timestamp": 1714963200000 }
// Role output
{ "role": "planner", "content": "...", "meta": { "phases": [...] }, "timestamp": ... }
```
### Thread commands
**`.info.jsonl`** — Structured debug log
| Command | Description |
|---------|-------------|
| `uwf thread start <workflow> -p <prompt>` | Create a thread (StartNode → CAS, head → threads.yaml). No execution. |
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle. |
| `uwf thread show <thread-id>` | Show thread head pointer and done status. |
| `uwf thread list [--all]` | List active threads (`--all` includes archived). |
| `uwf thread steps <thread-id>` | List all steps in chronological order. |
| `uwf thread read <thread-id> [--quota <chars>] [--before <hash>]` | Render thread as human-readable markdown. |
| `uwf thread fork <step-hash>` | Fork a thread from a specific CAS node. |
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML. |
| `uwf thread kill <thread-id>` | Terminate and archive a thread. |
```jsonc
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
```
### Workflow commands
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → instant code location.
| Command | Description |
|---------|-------------|
| `uwf workflow put <file.yaml>` | Register a workflow from YAML definition. |
| `uwf workflow show <id>` | Show workflow by name or CAS hash. |
| `uwf workflow list` | List registered workflows. |
## Execution Model
### CAS commands
- **No daemon.** `uncaged-workflow run <name>` starts a worker process
- Same bundle's threads share one process (memory efficiency)
- Process exits when all threads complete
- Thread termination via IPC within the process
| Command | Description |
|---------|-------------|
| `uwf cas get <hash>` | Read a CAS node. |
| `uwf cas put <type-hash> <data>` | Store a node, print its hash. |
| `uwf cas has <hash>` | Check if a hash exists. |
| `uwf cas refs <hash>` | List direct CAS references. |
| `uwf cas walk <hash>` | Recursive traversal from a node. |
| `uwf cas reindex` | Rebuild type index from all nodes. |
| `uwf cas schema list` | List registered schemas. |
| `uwf cas schema get <hash>` | Show a schema by type hash. |
## CLI Commands
### Setup
| Priority | Command | Description |
|----------|---------|-------------|
| P1 | `add <name> <file.esm.js>` | Register a bundle |
| P1 | `list` | List registered workflows |
| P1 | `show <name>` | Show workflow details |
| P1 | `remove <name>` | Remove a workflow |
| P1 | `run <name> [--prompt] [--max-rounds]` | Start a thread |
| P1 | `threads [name]` | List threads |
| P1 | `thread <id>` | Show thread state |
| P1 | `thread rm <id>` | Delete a thread |
| P1 | `ps` | List running threads |
| P1 | `kill <thread-id>` | Terminate a running thread |
| P2 | `history <name>` | Show version history |
| P2 | `rollback <name> [hash]` | Switch to a previous version |
| P2 | `pause <thread-id>` | Pause a running thread |
| P2 | `resume <thread-id>` | Resume a paused thread |
| P3 | `fork <thread-id> [--from-role <role>]` | Fork from historical state |
| Command | Description |
|---------|-------------|
| `uwf setup [--provider --base-url --api-key --model --agent]` | Configure provider/model/agent (interactive if no flags). |
All commands implemented and tested. ✅
## Toolchain
## Design Decisions
| Tool | Purpose |
|------|---------|
| **bun** | Package manager + runtime |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format |
| **vitest** | Test runner |
## Design decisions
| Decision | Rationale |
|----------|-----------|
| **Role = pure data** | Decouples definition from execution; same role with different agents |
| **Agent bound at runtime** | WorkflowDefinition is reusable; agent choice is deployment concern |
| **Three-phase context** | Each phase sees only what it needs; clean separation |
| **ExtractFn as general tool** | Agents use it for pre-execution extraction; engine uses it for meta |
| **Single-file ESM** | Hash = version, no dependency hell, self-contained |
| **No daemon** | OS handles process lifecycle; unnecessary complexity |
| **Crockford Base32** | Filesystem-safe, readable, compact |
| **No concurrency in registry** | Different workflows have different constraints; belongs at workflow/role level |
| **No dryRun** | Tests use mock agents + mock fetch; simpler architecture |
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
| **Stateless single-step CLI** | Each `uwf thread step` is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
| **JSONata moderator** | Declarative condition expressions evaluated against thread history. No LLM cost for routing decisions. |
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `json-cas`. No code generation, no runtime library dependency. |
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
@@ -1,5 +1,7 @@
# Workflow-as-Agent Implementation Plan
> ⚠️ This plan references the pre-split package structure. File paths have changed.
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Enable workflows to invoke other workflows as agents, backed by global CAS and refs tracking.
+262
View File
@@ -0,0 +1,262 @@
# RFC: CAS-Based Thread Storage
> Status: Draft
> Author: 小橘 🍊(NEKO Team)
> Date: 2026-05-09
## Summary
Replace `.data.jsonl` with a fully CAS-based thread state chain. Threads become linked lists of immutable CAS nodes, indexed by a per-bundle `threads.json`.
## Motivation
`.data.jsonl` is a flat append-only file with three different row formats (start, role step, end). This makes forking expensive (copy file), deduplication impossible (forked threads repeat shared history), and GC complex (must parse every row to find CAS refs).
Threads are inherently immutable append-only sequences — a natural fit for CAS hash chains, similar to git's commit DAG.
## Design
### Node Types
Two CAS node types, using the existing `{ type, payload, refs }` CAS blob structure:
#### StartNode
Contains workflow-level parameters. **No threadId** (because the same StartNode can be shared across forks). Prompt is stored as a CAS blob and referenced via `refs[0]`.
```
CAS blob:
{
type: "start",
payload: {
name: "solve-issue",
hash: "BUNDLE_HASH",
maxRounds: 10,
depth: 0
},
refs: [
<prompt_hash> // refs[0]: initial task prompt (CAS blob)
]
}
```
- No `role`, `content`, `meta` — this is not a step, it's workflow metadata
- Prompt is **not** inline — it lives in CAS and is referenced by hash
#### StateNode
One per role step (including `__end__`).
```
CAS blob:
{
type: "state",
payload: {
role: "coder",
meta: { ... },
start: "<start_hash>",
content: "<content_merkle_hash>",
ancestors: ["<parent_hash>", "<grandparent_hash>", ...],
compact: null,
timestamp: 1234567890
},
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
}
```
**Payload is the source of truth.** Application code reads named fields from payload. `refs[]` is a **GC index** — automatically derived from payload by collecting all CAS hashes. GC only scans `refs[]` without understanding payload structure.
**Payload fields:**
| Field | Type | Meaning |
|-------|------|---------|
| `role` | `string` | Role name, or `"__end__"` for completion |
| `meta` | `object` | Structured metadata extracted from agent output |
| `start` | `string` | StartNode hash |
| `content` | `string` | Content Merkle node hash (carries role artifact refs) |
| `ancestors` | `string[]` | `[parent, grandparent, ...]` — up to 11 entries (1 parent + 10 skip-list). Empty for first step after start. `ancestors[0]` is the direct parent. |
| `compact` | `string \| null` | CAS hash of a compacted summary of all nodes before this one. When present, LLM context assembly can use this instead of walking the full chain. |
| `timestamp` | `number` | Unix timestamp in ms |
### Content Merkle Node
The content at `refs[2]` of each StateNode is itself a CAS Merkle node. This is where **role artifact references** live:
```
CAS blob:
{
type: "content",
payload: "<role output text>",
refs: [
<artifact_hash_1>, // e.g. a commit, a file, a sub-result
<artifact_hash_2>,
...
]
}
```
The Extractor is responsible for producing both `meta` and `refs` from raw agent output:
```
Agent raw output
Extractor → { meta, contentPayload, refs[] }
CAS put content Merkle: { type: "content", payload: contentPayload, refs }
↓ contentHash
StateNode: { ..., refs: [start, parent, contentHash, ...ancestors] }
```
This keeps StateNode refs fixed and simple. All role-specific artifact references are encapsulated in the content Merkle node. GC follows: `thread head → StateNode.refs → content Merkle.refs → artifacts`, full chain recursive.
### End Node
An end is just a StateNode with `role: "__end__"`:
```
{
type: "state",
payload: {
role: "__end__",
meta: { returnCode: 0, summary: "completed successfully" },
start: "<start_hash>",
content: "<content_hash>",
ancestors: ["<parent_hash>", ...],
compact: null,
timestamp: 1234567891
},
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
}
```
### Thread Index: `threads.json`
Per-bundle directory, one `threads.json` file. **Only active (in-progress) threads** live here:
```
~/.uncaged/workflow/bundles/<hash>/threads.json
```
```json
{
"01JTHREAD1AAAAAAAAAAAAAAA": {
"head": "<latest_state_node_hash>",
"start": "<start_node_hash>",
"updatedAt": 1234567891
}
}
```
When a thread completes (`__end__`), it is **removed from `threads.json`** and appended to a date-partitioned history file:
```
~/.uncaged/workflow/bundles/<hash>/history/{YYYY-MM-DD}.jsonl
```
Each line:
```json
{"threadId":"01JTHREAD1AAAAAAAAAAAAAAA","head":"<end_node_hash>","start":"<start_node_hash>","completedAt":1234567891}
```
Benefits:
- `threads.json` stays small — only in-flight threads
- Dashboard watches `threads.json` for real-time updates; completed threads don't trigger watches
- History is queryable by date but not actively monitored
- GC roots = all heads from `threads.json` + all heads from `history/*.jsonl`
### Ancestor Skip-List
Each StateNode carries up to 11 entries in `payload.ancestors` (1 parent + 10 skip-list, newest first):
```
Node 15: ancestors = [node14, node13, node12, node11, node10, node9, node8, node7, node6, node5, node4]
^parent ^--- skip-list (10 most recent) ---^
```
This enables:
- **Paginated fetch**: jump to any recent ancestor without walking the full chain
- **Partial replay**: fetch last N steps without loading the entire history
- The list is capped at 10 to keep node size bounded
### Fork
Forking a thread at step N:
1. Create new threadId
2. Create a new StateNode whose `parent` (refs[1]) points to the fork point's StateNode
3. Register the new threadId in `threads.json` with its own head
4. **Zero data duplication** — the forked thread shares all ancestor nodes via CAS
### Compact
When a StateNode has `payload.compact` set:
```json
{
"type": "state",
"payload": {
"role": "coder",
"meta": { ... },
"compact": "<cas_hash_of_summary>",
"timestamp": 1234
},
"refs": [...]
}
```
This means: "everything before this node has been summarized into the blob at `compact`". When building LLM context:
1. Walk back from head
2. If a node has `compact`, stop walking — use the compact summary + all nodes after it
3. If no compact found, use full chain
This enables long-running threads without unbounded context growth.
### GC
Simple mark-and-sweep:
1. **Roots**: all `head` and `start` hashes from `threads.json` + all `history/*.jsonl` files
2. **Mark**: from each root, recursively mark all reachable hashes via `refs[]` (including content Merkle → artifact refs)
3. **Sweep**: delete unmarked CAS blobs
No per-row format parsing needed. GC only needs to understand `refs[]`.
### refs[] Derivation
`refs[]` is auto-derived from payload at write time via a `collectRefs(payload)` function that extracts all CAS hash strings from named fields (`start`, `content`, `ancestors`, `compact`). Application code never reads `refs[]` — it reads named payload fields. This makes `refs[]` a pure GC optimization with zero semantic coupling.
### Extract Phase
The Extractor is expanded from the current design. Currently it only extracts `meta` from agent output. In the new design it extracts:
| Output | Purpose |
|--------|---------|
| `meta` | Structured metadata (same as before) |
| `contentPayload` | The text payload for the content Merkle node |
| `refs[]` | CAS hashes of artifacts produced by this role step |
The `refs[]` become the content Merkle node's refs, enabling GC to trace all role-produced artifacts.
## What Stays Unchanged
- `.info.jsonl` — debug logging stays as-is (high-frequency append, not suitable for CAS)
- CAS blob storage format (`~/.uncaged/workflow/cas/`)
- Bundle registry (`workflow.yaml`)
## Migration
Breaking change. Old `.data.jsonl` files become incompatible. No backward compat fallback (per project convention).
## Changes by Package
| Package | Changes |
|---------|---------|
| `workflow-protocol` | Replace `StartStep`, `RoleStep` types with `StartNode`, `StateNode`. Add `ContentMerkleNode` type. Expand `ExtractResult` to include `refs[]`. |
| `workflow-cas` | Add `findReachableHashes(roots)` for GC mark phase |
| `workflow-execute` | Rewrite engine to write CAS nodes + update `threads.json` instead of appending JSONL. Move completed threads to `history/`. Simplify `gc.ts`. Simplify `fork-thread.ts`. Expand extract phase to produce refs. |
| `workflow-runtime` | `ThreadContext` built by walking chain from head. `start.prompt` resolved from CAS via StartNode.refs[0]. |
| `cli-workflow` | `thread list/show/rm` read from `threads.json` + `history/`. SSE watches `threads.json`. |
| `workflow-dashboard` | Watch `threads.json` instead of `.data.jsonl` |
| Templates & Agents | Update extract definitions to produce `refs[]`. Update `ctx.start.content` → CAS resolved. |
+197
View File
@@ -0,0 +1,197 @@
# RFC: Merkle Call Stack — Cross-Thread DAG Linking
**Author:** 小橘 🍊(NEKO Team)
**Date:** 2026-05-11
**Status:** Draft
## Problem
`workflowAsAgent` 在父 workflow 中 spawn 子 workflow 时,父子 thread 之间没有任何 Merkle 链接:
1. **子 thread 不知道自己从哪来** — start node 只有 prompt hash,无法追溯父 thread 的上下文(preparer 分析出的 repoPath、conventions 等)
2. **父 thread 不知道子 thread 在哪** — developer role 的 state node 里只有 agent 返回的文本,child thread root hash 埋在字符串里,不是结构化 ref
3. **上下文传递靠序列化到 prompt** — 父 workflow 前置 role 的产出只能通过拼字符串传给子 workflow,丢失了 Merkle DAG 的可遍历性
## Proposal
在 CAS 节点中建立父子 thread 之间的 **双向 Merkle 链接**,形成调用栈结构。
### 新增字段
#### StartNodePayload(子 → 父)
```typescript
type StartNodePayload = {
name: string;
hash: string;
depth: number;
parentState: string | null; // NEW: 父 thread 调用时的 head state hash
};
```
`parentState` 指向子 workflow 被 spawn 时,父 thread 的最后一个 state node hash。这是"调用发生时的调用栈帧"。
#### StateNodePayload(父 → 子)
```typescript
type StateNodePayload = {
role: string;
meta: Record<string, unknown>;
start: string;
content: string;
ancestors: string[];
compact: string | null;
timestamp: number;
childThread: string | null; // NEW: 子 thread 最终 state hash(执行结果)
};
```
`childThread` 指向子 thread 完成后的**最终 state hash**(不是 start)——语义上是"函数返回值",从这里沿 ancestors 可回溯子 thread 的完整执行历史。
### refs 同步
新增的 hash 也必须放进 `refs[]`
- `StartNode.refs`: `[promptHash, parentState]`(parentState 非 null 时)
- `StateNode.refs`: `[...existingRefs, childThread]`(childThread 非 null 时)
原因:GC 的 `findReachableHashes` 只走 `refs`,不解析 payload 字段。字段提供语义,refs 保证可达性。
### 具体 DAG 结构
`solve-issue`(fix #191)为例,developer role 委托给 `develop` 子 workflow:
```
父 thread: solve-issue
═══════════════════════════════════════════════════════════
content("fix #191")
hash: ABCD1234
start(solve-issue)
hash: START001
payload: { name: "solve-issue", hash: BUNDLE_SI, depth: 0, parentState: null }
refs: [ABCD1234]
state(preparer)
hash: STATE_P1
payload: { role: "preparer", meta: { repoPath: "...", ... }, childThread: null, ... }
refs: [PREP_CONTENT]
state(developer) ──────── 父→子 ────────
hash: STATE_D1 │
payload: { role: "developer", meta: { ... }, childThread: ★CSTATE_END, ... }
refs: [DEV_CONTENT, ★CSTATE_END] │
state(submitter) │
hash: STATE_S1 │
payload: { role: "submitter", ..., childThread: null } │
子 thread: develop │
═══════════════════════════════════════════════════════════ │
content("fix #191") (CAS 去重,可能同 ABCD1234) │
hash: CPROMPT1 │
──────── 子→父 ──────── │
start(develop) │ │
hash: CHILD_START │ │
payload: { name: "develop", hash: BUNDLE_DEV, depth: 1, │
parentState: ★STATE_P1 } │ │
refs: [CPROMPT1, ★STATE_P1] │ │
│ │
state(planner) │ │
hash: CSTATE_1 │ │
... │ │
│ │
state(coder) │ │
hash: CSTATE_2 │ │
... │ │
│ │
state(reviewer) → state(tester) → state(committer) │
│ │
hash: ★CSTATE_END ◄─────────────────┼─────────────────────────┘
```
### 遍历路径
**子 thread agent 获取父上下文(上行):**
```
当前 step → start(CHILD_START)
→ refs[1] = STATE_P1(父 preparer 的 state)
→ payload.meta.repoPath = "/home/.../workflow"
→ refs → PREP_CONTENT(完整 preparer 输出)
→ payload.start = START001(父的 start node)
→ refs[0] = ABCD1234(原始 prompt)
```
**从父 thread 追踪子 thread 执行(下行):**
```
STATE_D1(父 developer state)
→ payload.childThread = CSTATE_END
→ 子 thread 最终 state
→ 沿 ancestors 回溯:committer → tester → reviewer → coder → planner
→ payload.start = CHILD_START(子 thread 入口)
```
**完整调用栈还原:**
```
任意节点 → 沿 start 找到所属 thread 的 StartNode
→ parentState 非 null?沿 parentState 进入父 thread
→ 递归直到 parentState = null(顶层 workflow)
```
## Implementation Plan
### Phase 1: Protocol + CAS 层
1. `workflow-protocol/src/cas-types.ts``StartNodePayload``parentState: string | null``StateNodePayload``childThread: string | null`
2. `workflow-cas/src/nodes.ts``putStartNode` 接受可选 `parentStateHash`,放入 refs;`putStateNode` 接受可选 `childThreadHash`,放入 refs
3. `workflow-cas/src/nodes.ts` — 解析逻辑兼容新字段(缺失时视为 null)
### Phase 2: Engine 层
4. `workflow-execute/src/engine/engine.ts``executeThread` 接受 `parentStateHash: string | null`,传给 `putStartNode`
5. `workflow-execute/src/workflow-as-agent.ts` — spawn 子 thread 时传入父 thread 当前 head state hash 作为 `parentStateHash`;子 thread 完成后返回最终 state hash
6. Engine 写 developer role 的 state node 时,把子 thread 最终 hash 写入 `childThread` 字段
### Phase 3: Agent 可观测性
7. Agent prompt 构建(`buildAgentPrompt`)— 当 start node 有 `parentState` 时,提示 agent 可通过 `cas get` 遍历父上下文
8. CLI `thread show` — 显示 parentState / childThread 链接关系
### Phase 4: 验证
9. 已有测试适配新字段(向后兼容,旧节点 parentState/childThread 为 null)
10. 新增集成测试:workflowAsAgent 场景下验证双向链接正确写入
## Design Decisions
### 为什么 childThread 指向 end 而不是 start?
- 语义是"函数返回值"——父 role 执行完才产出 state,此时子 thread 已跑完
- 从 end 沿 ancestors 可回溯到 start;反过来 start 写入时子 thread 还没跑完,无法知道 end
### 为什么 parentState 指向 state 而不是 start?
- 指向父 thread 调用点的**前一个 state**(即调用发生时的 head)
- 这是子 workflow 能看到的父上下文的"切面"——所有已完成的前置 role 都可达
- 如果是第一个 role 就 spawn 子 workflow(没有前置 state),parentState 指向父的 start node
### 为什么同时放字段和 refs?
- `refs[]` 服务于 GC(`findReachableHashes` 只遍历 refs)和通用 DAG 遍历
- `payload.parentState` / `payload.childThread` 服务于语义读取(明确知道哪个 ref 是什么)
- 不改 GC 逻辑,只加字段,GC 自然正确
### 向后兼容
- 新字段默认 `null`,旧节点解析时缺失字段视为 `null`
- 不影响已有 thread 的遍历和 GC
- `depth` 可通过沿 parentState 链上溯来交叉验证(数据自证)
## Open Questions
1. **多子 thread** — 如果一个 role 需要 spawn 多个子 workflow(目前不存在这个场景),`childThread` 应该改成 `childThreads: string[]` 还是保持单个?
2. **Agent prompt 注入深度** — 子 workflow 的 agent 应该自动遍历多少层父上下文?全部还是限制深度?
3. **CLI 展示**`thread show` 要不要递归展示整个调用栈,还是只显示直接链接?
@@ -0,0 +1,224 @@
# Dashboard Workflow Graph Visualization
**Issue**: #198
**Status**: In Progress
**Author**: xingyue
## Overview
在 Dashboard 的 ThreadDetail 页面中嵌入一个交互式流程图,将 workflow 的 `ModeratorTable` 可视化为有向图。用户可以一眼看到角色流转结构和当前执行进度。
## 数据层(✅ 已完成 — PR #201)
### WorkflowGraph 类型
`WorkflowDefinition.moderator`(函数)已替换为 `WorkflowDefinition.table`(声明式 `ModeratorTable`),`buildDescriptor` 自动从 table 提取 graph:
```ts
type WorkflowGraphEdge = {
from: string; // source role 或 "__start__"
to: string; // target role 或 "__end__"
condition: string; // condition.name 或 "FALLBACK"
conditionDescription: string | null;
};
type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph; // 必填,新 bundle 自动生成
};
```
### 数据流
```
ModeratorTable (WorkflowDefinition.table)
→ buildDescriptor() 自动提取 graph
→ descriptor.yaml 持久化(hash.yaml)
→ CLI serve /workflows/:name API 返回 descriptor
→ Dashboard 前端拿到 graph
```
### 剩余数据层工作
**serve API 需要返回 descriptor**:当前 `GET /workflows/:name` 只返回 registry entry(hash + timestamp),不含 descriptor。需要从 `bundles/{hash}.yaml` 读取 descriptor 并返回给前端。
方案:在 `routes-workflow.ts``GET /workflows/:name` 响应中附带 `descriptor` 字段。或者:thread-detail 发现 workflow name 后,请求 `GET /workflows/:name/descriptor` 拿到 graph。
## 前端渲染
### 库选型:React Flow + dagre
| 库 | 优势 | 劣势 |
|---|---|---|
| **React Flow** ✅ | React 原生、自定义节点/边、dagre 自动布局、~50KB gzip | 需要学 API |
| Mermaid | 声明式简单 | 无交互、无法高亮当前步骤 |
| D3 | 完全控制 | 太底层,手撸成本高 |
| Cytoscape | 图论强 | React 集成差 |
**依赖新增**
```json
{
"@xyflow/react": "^12",
"@dagrejs/dagre": "^1"
}
```
### 图结构映射
```
WorkflowGraph.edges → React Flow nodes + edges
节点(自动从 edges 推导):
- __start__ → 圆形小节点(入口)
- role → 圆角矩形,显示 role name + description
- __end__ → 圆形小节点(终止)
边:
- FALLBACK → 虚线(dashed),无 label
- condition → 实线,label = condition
hover tooltip = conditionDescription
```
### 布局
使用 dagre 自动计算 TB(top-to-bottom)方向布局:
```ts
import Dagre from "@dagrejs/dagre";
function layoutGraph(nodes, edges) {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
for (const node of nodes) {
g.setNode(node.id, { width: 180, height: 60 });
}
for (const edge of edges) {
g.setEdge(edge.source, edge.target);
}
Dagre.layout(g);
return nodes.map((node) => {
const pos = g.node(node.id);
return { ...node, position: { x: pos.x - 90, y: pos.y - 30 } };
});
}
```
### 运行时高亮
ThreadDetail 已有 `records: ThreadRecord[]`,其中 `RoleRecord.role` 就是当前/历史执行的 role。
高亮逻辑:
```ts
function getNodeStates(records: ThreadRecord[]): Map<string, "completed" | "active"> {
const states = new Map<string, "completed" | "active">();
const roleRecords = records.filter((r) => r.type === "role");
for (let i = 0; i < roleRecords.length; i++) {
const role = roleRecords[i].role;
states.set(role, i === roleRecords.length - 1 ? "active" : "completed");
}
// 如果有 workflow-result,最后一个 role 也是 completed
if (records.some((r) => r.type === "workflow-result")) {
for (const [k] of states) {
states.set(k, "completed");
}
states.set("__end__", "completed");
}
states.set("__start__", "completed");
return states;
}
```
节点样式:
| 状态 | 样式 |
|------|------|
| default | `border: var(--color-border)`, 暗色背景 |
| completed | `border: var(--color-success)`, 绿色边框 + ✓ 图标 |
| active | `border: var(--color-accent)`, 蓝色边框 + 脉冲动画 |
边高亮:当 source 和 target 都至少 completed 时,边变绿。
## 组件结构
```
workflow-dashboard/src/
components/
workflow-graph/
types.ts — NodeState 等前端类型
index.ts — export { WorkflowGraph }
workflow-graph.tsx — 主组件,React Flow canvas
role-node.tsx — 自定义 role 节点
terminal-node.tsx — START/END 圆形节点
condition-edge.tsx — 自定义边(虚线/实线 + label)
use-layout.ts — dagre 布局 hook
```
### 集成到 ThreadDetail
在 ThreadDetail 中,records 列表上方插入可折叠的图面板:
```tsx
// thread-detail.tsx
{graph && (
<div className="mb-4 border rounded-lg overflow-hidden" style={{ height: 300 }}>
<WorkflowGraph graph={graph} nodeStates={getNodeStates(records)} />
</div>
)}
```
图高度固定 300px,React Flow 支持 pan + zoom,不影响下方 records 滚动。
## 实施计划
### ~~Phase 0: 数据层~~ ✅ Done (PR #201)
- [x] `WorkflowDefinition.moderator``table` (ModeratorTable)
- [x] `WorkflowDescriptor` 新增 `graph: WorkflowGraph`
- [x] `buildDescriptor` 自动提取 graph
- [x] `validateWorkflowDescriptor` 校验 graph
### Phase 1: API + 静态图渲染
1. serve API:`GET /workflows/:name` 返回 descriptor(含 graph),或新增 `GET /workflows/:name/descriptor`
2. Dashboard `api.ts` 新增 `getWorkflowDescriptor(agent, name)` 函数
3. 安装 `@xyflow/react` + `@dagrejs/dagre`
4. 实现 `workflow-graph/` 组件集
5. ThreadDetail 中集成:从 thread-start record 拿 workflow name → 请求 descriptor → 渲染图
**产出**:打开 ThreadDetail 看到 workflow 流程图,无高亮。
### Phase 2: 运行时高亮
1. ThreadDetail 根据 records 计算 nodeStates
2. 节点/边样式响应状态变化
3. SSE live 模式下实时更新高亮
**产出**:正在运行的 thread 能看到当前执行到哪个 role。
### Phase 3: 交互增强
1. 点击节点滚动到对应 role 的 RecordCard
2. 边 hover 显示 conditionDescription tooltip
3. 节点 hover 显示 role description + schema summary
**产出**:图和记录列表联动。
## 注意事项
- **自循环边**:如 `coder → coder (FALLBACK)`,React Flow 支持自循环,dagre 需要特殊处理(self-edge 用 loop 路径)
- **大图性能**:dagre 在 <50 节点时性能无忧,workflow 通常 <10 个 role
- **暗色主题**:Dashboard 已使用 CSS variables,节点/边样式复用现有色板
- **不提交 pnpm-lock.yaml**
+191
View File
@@ -0,0 +1,191 @@
# workflow-agent-react — ReAct Agent Package
**Status**: RFC v3
**Author**: 小橘 🍊
## Problem
现有的 agent 包都依赖外部 CLI 进程:
| Package | 机制 | 能力 |
|---------|------|------|
| `workflow-agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
## 核心设计变更:AdapterFn 替代 AgentFn
### 现状的问题
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
```
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
```
### 新抽象:AdapterFn
```typescript
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
```
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
- **`schema`** — role 的 meta schema,定义输出格式
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
### AgentContext 不再需要
`AgentContext``ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
### createWorkflow 签名变更
```typescript
// Before
type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// After
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
```
engine 对每个 role 的执行逻辑:
```typescript
// Before
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
// After
const roleFn = adapter(role.systemPrompt, role.metaSchema);
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
```
## `createReactAdapter` — 复用 workflow-reactor
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor``ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
```typescript
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { ThreadContext, LlmProvider } from "@uncaged/workflow-protocol";
import type { ToolDefinition } from "@uncaged/workflow-reactor";
type ReactToolHandler = (name: string, args: string) => Promise<string>;
type ReactAdapterConfig = {
provider: LlmProvider;
tools: readonly ToolDefinition[];
toolHandler: ReactToolHandler;
maxRounds: number;
};
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const reactor = createThreadReactor<ThreadContext>({
llm: createLlmFn(config.provider),
staticTools: config.tools,
structuredToolFromSchema: (s) => buildStructuredTool(s),
systemPromptForStructuredTool: () => prompt,
toolHandler: (call, ctx) =>
config.toolHandler(call.function.name, call.function.arguments),
maxRounds: config.maxRounds,
});
return async (ctx: ThreadContext): Promise<T> => {
const input = buildThreadInput(ctx);
const result = await reactor({ thread: ctx, input, schema });
if (!result.ok) throw new Error(result.error);
return result.value;
};
};
}
```
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
## `agentToAdapter` — 向后兼容
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`
```typescript
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext): Promise<T> => {
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
const result = await agent(agentCtx);
const output = typeof result === "string" ? result : result.output;
return extract(output, schema, extractProvider);
};
};
}
```
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
## 包结构
```
packages/workflow-agent-react/
src/
types.ts # ReactAdapterConfig, ReactToolHandler
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
thread-input.ts # ThreadContext → user message string
index.ts
__tests__/
create-react-adapter.test.ts
package.json
```
依赖:
- `@uncaged/workflow-protocol``ThreadContext`, `LlmProvider`
- `@uncaged/workflow-reactor``createLlmFn`, `createThreadReactor`, types
## 影响范围
### Breaking Changes
| 改动 | 影响 |
|------|------|
| `AgentBinding``AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
### 需修改的包
1. `workflow-protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
2. `workflow-runtime` — 更新 re-export
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
4. `workflow-util-agent``buildAgentPrompt``buildThreadInput`,接收 `ThreadContext`
5. 所有 bundle-entry — `agent:``adapter:`
### 不受影响
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `workflow-dashboard`
- `workflow-agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
## Phases
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
## 工具集(后续讨论)
| 工具 | 说明 | 优先级 |
|------|------|--------|
| `read_file` | 读文件 | P0 |
| `write_file` | 写文件 | P0 |
| `patch_file` | find-and-replace 编辑 | P0 |
| `shell_exec` | 执行 shell 命令 | P0 |
| `search_files` | grep / find | P1 |
| `list_files` | ls | P1 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
**日期:** 2026-05-18
---
## 概述
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
| 包 | npm name | 职责 |
|---|---|---|
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
---
## 一、`workflow-template-document`
### Thread 启动输入
```typescript
// src/types.ts
type DocumentStartInput = {
prompt: string; // 用户指令
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
};
```
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
### 角色与 Meta
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
```typescript
const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(), // 生成产物绝对路径
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
}),
]);
type WriterMeta = z.infer<typeof writerMetaSchema>;
// differ:仅编辑模式执行
const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
type DifferMeta = z.infer<typeof differMetaSchema>;
```
两个角色的 `systemPrompt` 均为 `""`
### 调度表
```
START → writer ──(mode = "edit")──→ differ → END
↘(mode = "generate")→ END
```
### 公开导出
template 导出两个对象供消费方使用:
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow``def` 参数
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
```typescript
// bundle 侧用法
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
```
### 包文件结构
```
packages/workflow-template-document/
src/
types.ts # DocumentStartInput
roles/
writer.ts # writerMetaSchema, WriterMeta, writerRole
differ.ts # differMetaSchema, DifferMeta, differRole
index.ts
roles.ts # DocumentMeta, documentRoles
moderator.ts # writerIsEditMode condition + documentTable
definition.ts # documentWorkflowDefinition
descriptor.ts # buildDocumentDescriptor()
index.ts
__tests__/
moderator.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"zod": "^4.0.0"
}
```
---
## 二、`workflow-agent-office`
### office-agent CLI 接口
```bash
# 生成模式:在 CWD 生成 output.docx
office-agent create "<prompt>" -o output.docx
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
office-agent edit modified.docx "<instruction>"
```
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
- 输出文件落到调用方设定的 CWD
- 退出码 0 = 成功,非零 = 失败
### 文件命名约定
| 模式 | 文件 | 路径 |
|---|---|---|
| generate | 输出 | `<outputDir>/output.docx` |
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
| edit | 修改后产物 | `<outputDir>/modified.docx` |
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx``original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
### 执行流程
**生成模式(`inputDocx = null`):**
1. `mkdir -p <outputDir>``<config.outputDir>/<ctx.threadId>`
2. `const command = config.command ?? "office-agent"`
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
4. 验证 `outputDir/output.docx` 存在
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
**编辑模式(`inputDocx ≠ null`):**
1. `mkdir -p <outputDir>`
2. `copyFile(inputDocx, <outputDir>/original.docx)`
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
4. `const command = config.command ?? "office-agent"`
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
6. 验证 `outputDir/modified.docx` 存在
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
### AdapterFn 实现(直接实现,不经过 runtime.extract)
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
```typescript
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
### 配置
```typescript
type OfficeAgentConfig = {
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
command: string | null; // null → runner 内 resolve 为 "office-agent"
timeout: number | null; // null → 不设超时;单位 ms
};
```
### 错误处理
```typescript
if (!result.ok) {
const e = result.error;
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
// "spawn_failed"
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
if (!existsSync(expectedPath))
throw new Error(`office-agent: output file not found: ${expectedPath}`);
```
### packageDescriptor
```typescript
// src/package-descriptor.ts
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: { type: "string", description: "Root directory for workflow outputs." },
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-office/
src/
types.ts # OfficeAgentConfig, OfficeAgentOpt
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
agent.ts # createOfficeAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
}
```
---
## 三、`workflow-agent-docx-diff`
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
### docx-diff 退出码约定
| 退出码 | 含义 | runner 处理 |
|---|---|---|
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
| 2+ | 错误 | throw |
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
### 执行流程
```
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
2. 验证 mode === "edit"(否则 throw)
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
4. const command = config.command ?? "docx-diff"
5. spawnCli(command,
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null })
exit 0 或 1 → 验证 diffDocx 存在
exit 2+ → throw
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
```
### AdapterFn 实现(直接实现,不经过 runtime.extract)
```typescript
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find(s => s.role === "writer");
if (!writerStep) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const raw = await runDocxDiff(config, writerMeta);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
### 配置
```typescript
type DocxDiffAgentConfig = {
command: string | null; // null → runner 内 resolve 为 "docx-diff"
};
```
### packageDescriptor
```typescript
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-docx-diff/
src/
types.ts # DocxDiffAgentConfig
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
agent.ts # createDocxDiffAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^"
}
```
---
## 四、外部 bundle(外部 workspace 消费)
```typescript
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
import {
buildDocumentDescriptor,
documentWorkflowDefinition,
} from "@uncaged/workflow-template-document";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
import { join } from "node:path";
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, {
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
overrides: { differ: createDocxDiffAgent() },
});
```
---
## 不在范围内
- 重试逻辑(失败直接 throw)
- office-agent server 的启停管理(假设 server 已在运行)
- docx-diff HTML/terminal 格式输出(仅 docx)
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
+539
View File
@@ -0,0 +1,539 @@
# `uwf` — Stateless Workflow CLI
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
---
## 1. CLI Design
### 1.1 命令总览
```
# thread 组
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
uwf thread step <thread-id> [--agent] # 单步执行
uwf thread show <thread-id> # thread-id → head 查询
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
uwf thread kill <thread-id> # 终结 thread,归档
# workflow 组
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
uwf workflow show <workflow-id> # 查看 workflow 定义
uwf workflow list # 列出已注册 workflows
```
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
### 1.2 `uwf thread start`
```bash
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
```
- `<workflow>` — workflow 名或 CAS hash
- `-p` — 用户 prompt(必填)
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
}
```
**做的事:**
1. 解析 workflow(名字查 registry → CAS hash)
2. 生成 thread ULID
3. 写 StartNode 到 CAS
4. 在 threads.yaml 中记录链头 → StartNode hash
5. 输出 JSON
### 1.3 `uwf thread step`
```bash
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
"done": false // true = moderator 返回 END,thread 已归档
}
```
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
详细信息通过 `uwf thread show <thread-id>``json-cas get <head>` 查看。
**做的事:**
1. 读链头 → 当前 StepNode(或 StartNode)
2. 收集 thread 历史(遍历链)
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
4. 若 END → 归档 thread,输出最后链头,退出
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
7. 更新链头指针
8. 再次调 moderator(基于新 StepNode)判断 done
9. 输出 JSON
### 1.4 `uwf thread show`
```bash
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA",
"done": false
}
```
纯 thread-id → head 查询。详细内容用 `json-cas get <head>``json-cas walk <head>` 查看。
### 1.5 Agent CLI 协议
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
```bash
uwf-hermes <thread-id> <role>
```
**约定:**
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
- agent 执行实际逻辑,agent-kit 负责 extract
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
- 所有配置从环境变量读(LLM model、API key、extractor config)
- exit 0 = 成功,非 0 = 失败
**stdout 输出:**
```
8FWKR3TN5V1QA
```
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
---
## 2. CAS 结构定义
### 2.1 类型层级
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
### 2.2 数据节点
#### `Workflow`
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent..."
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
developer:
description: "Implements code changes"
goal: "You are a developer agent..."
capabilities: [file-edit, shell]
procedure: "Implement the plan."
output: "List all files changed."
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer..."
capabilities: [code-review]
procedure: "Review the implementation."
output: "Approve or reject with comments."
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
conditions:
needsClarification:
description: "Planner requests clarification from user"
expression: "$exists(steps[-1].output.needsClarification)"
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null # 无条件(fallback)
planner:
- role: "developer"
condition: "needsClarification"
- role: "$END"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `conditions``Record<Name, JSONata>`,命名条件,方便画图描述
- `graph``Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
- `condition` 引用 conditions 中的 key,`null` = fallback
- 按数组顺序求值,第一个匹配的 transition 胜出
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
JSONata 表达式的求值上下文:
```jsonc
{
"start": { // StartNode 信息
"workflow": "4KNM2PXR3B1QW",
"prompt": "Fix the login bug..."
},
"steps": [ // 所有已完成 steps,从旧到新
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
]
}
```
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
#### `StartNode`(Thread 起点)
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
prompt: "Fix the login bug..."
```
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
- 没有 agent binding — 运行时从 config.yaml 解析
#### `StepNode`(Thread 每一步)
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
```
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `agent` — 纯字符串,不是 CAS 节点
### 2.3 链式结构
```
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → CAS(Workflow)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── start ──→ (same StartNode)
│ ├── prev ──→ StepNode (step 1)
│ │ ├── start ──→ (same StartNode)
│ │ ├── prev: null
│ │ ├── role: "planner"
│ │ └── ...
│ ├── role: "developer"
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(raw output | sub-workflow terminal node)
└── agent: "uwf-hermes"
```
### 2.4 可变状态
系统两个顶层 YAML 文件和一个 env 文件:
```yaml
# ~/.uncaged/workflow/config.yaml — 全局配置
providers:
openai:
baseUrl: "https://api.openai.com/v1"
apiKeyEnv: "OPENAI_API_KEY"
anthropic:
baseUrl: "https://api.anthropic.com/v1"
apiKeyEnv: "ANTHROPIC_API_KEY"
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
```
```yaml
# ~/.uncaged/workflow/threads.yaml — active thread 链头指针
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
```
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
```bash
# ~/.uncaged/workflow/.env — 敏感信息(API keys)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
```
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
- `threads.yaml` — 运行时状态
---
## 3. 包结构
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`
```
packages/
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
├── workflow-moderator/ # @uncaged/workflow-moderator — JSONata moderator 引擎
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
```
**外部依赖:**
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
**现有包全部保留不动**,新旧并存,逐步迁移。
---
## 4. 关键数据类型
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
### 4.1 公共类型
```typescript
/** CAS hash — XXH64, 13-char Crockford Base32 */
type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
```
### 4.2 Workflow 定义
```typescript
type RoleDefinition = {
description: string;
goal: string;
capabilities: string[];
procedure: string;
output: string;
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
};
type Transition = {
role: string; // 目标 role 名 或 "$END"
condition: string | null; // 引用 conditions 中的 key,null = fallback
};
type ConditionDefinition = {
description: string;
expression: string; // JSONata expression
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
};
```
### 4.3 Thread 节点
```typescript
type StartNodePayload = {
workflow: CasRef; // cas_ref → Workflow
prompt: string;
};
type StepNodePayload = StepRecord & {
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
};
```
### 4.4 JSONata 求值上下文
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
```typescript
/** JSONata 上下文中的 step — output 被展开 */
type StepContext = Omit<StepRecord, "output"> & {
output: unknown; // 展开后的 CAS 节点内容,非 hash
};
type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[]; // 从旧到新
};
```
### 4.5 CLI 输出
```typescript
/** uwf thread start */
type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
/** uwf thread list */
type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
```
### 4.6 配置
```typescript
/** Alias types for config references */
type AgentAlias = string;
type ModelAlias = string;
type ProviderAlias = string;
type WorkflowName = string;
type RoleName = string;
type Scenario = string; // e.g. "extract"
type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string; // env var name to read API key from
};
type ModelConfig = {
provider: ProviderAlias;
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
};
type AgentConfig = {
command: string;
args: string[];
};
/** ~/.uncaged/workflow/config.yaml */
type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
/** ~/.uncaged/workflow/threads.yaml */
type ThreadsIndex = Record<ThreadId, CasRef>;
// ^ thread-id ^ head StepNode/StartNode hash
```
### 4.7 类型关系图
```
WorkflowConfig (config.yaml)
ThreadsIndex (threads.yaml) ← 唯二可变状态
│ thread-id → head hash
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
│ │ │
├── start → StartNodePayload│ │ (output 展开)
├── prev → StepNodePayload │ │
│ ├── role ├── role
│ ├── output (CasRef) ├── output (展开)
│ ├── detail (CasRef) ├── detail (CasRef)
│ └── agent (string) └── agent (string)
└── start.workflow → WorkflowPayload
├── roles: Record<name, RoleDefinition>
├── conditions: Record<name, JSONata>
└── graph: Record<role, Transition[]>
```
+41
View File
@@ -0,0 +1,41 @@
name: "analyze-topic"
description: "Single-role topic analysis using four-phase role description"
roles:
analyst:
description: "Analyzes a given topic and produces a structured summary"
goal: |
You are a research analyst with expertise in breaking down complex topics
into clear, structured summaries. You think critically and cite key points.
capabilities:
- research
- critical-thinking
- structured-writing
procedure: |
Analyze the topic by:
1. Identifying the main thesis or question
2. Listing 3-5 key points with brief explanations
3. Noting any counterarguments or caveats
Keep your analysis concise (under 500 words).
output: |
Provide your analysis as markdown under the frontmatter.
The frontmatter must include your structured findings.
frontmatter:
type: object
properties:
thesis:
type: string
keyPoints:
type: array
items:
type: string
caveats:
type: string
required: [thesis, keyPoints]
conditions: {}
graph:
$START:
- role: "analyst"
condition: null
analyst:
- role: "$END"
condition: null
+75
View File
@@ -0,0 +1,75 @@
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent. You analyze issues and create step-by-step plans."
capabilities:
- issue-analysis
- planning
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
output: "Output the plan summary and list of concrete steps."
frontmatter:
type: object
properties:
plan:
type: string
steps:
type: array
items:
type: string
required: [plan, steps]
developer:
description: "Implements code changes"
goal: "You are a developer agent. You implement code changes according to plans."
capabilities:
- file-edit
- shell
- testing
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
output: "List all files changed and provide a summary of the implementation."
frontmatter:
type: object
properties:
filesChanged:
type: array
items:
type: string
summary:
type: string
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. You review implementations for correctness and quality."
capabilities:
- code-review
- static-analysis
procedure: "Review the implementation against the plan. Check for bugs, edge cases, and style."
output: "Approve or reject with detailed comments explaining your decision."
frontmatter:
type: object
properties:
approved:
type: boolean
comments:
type: string
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
graph:
$START:
- role: "planner"
condition: null
planner:
- role: "developer"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
+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
@@ -2,14 +2,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createContentMerkleNode,
getGlobalCasDir,
getRegisteredWorkflow,
readWorkflowRegistry,
serializeMerkleNode,
} from "@uncaged/workflow";
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
import {
cmdAdd,
@@ -22,10 +17,7 @@ import {
} from "../src/commands/workflow/index.js";
import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
`;
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
`;
function casStoredForm(raw: string): string {
@@ -57,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" };
}
@@ -158,11 +150,11 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
schema: { type: "object", properties: { greeting: { type: "string" } } },
},
},
graph: { edges: [] },
};
${wfPutImport}
export const run = async function* (input, options) {
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" };
};
@@ -201,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" };
}
@@ -232,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" };
}
@@ -265,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" };
}
@@ -288,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" };
}
@@ -330,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" };
}
@@ -382,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" };
}
@@ -395,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" };
}
@@ -450,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" };
}
@@ -467,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" };
}
@@ -1,15 +1,15 @@
import { describe, expect, test } from "bun:test";
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow";
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)),
@@ -77,6 +77,83 @@ describe("serve /api/cas", () => {
});
});
describe("serve error handling", () => {
test("POST /api/threads with invalid JSON body → 400", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("invalid JSON body");
});
test("POST /api/cas with invalid JSON body → 400", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/cas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("invalid JSON body");
});
test("POST /api/threads with missing required fields → 400", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ foo: "bar" }),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("required");
});
test("global error handler returns 500 with JSON", async () => {
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
app.get("/test-error", () => {
throw new Error("boom");
});
const res = await app.fetch(new Request("http://localhost/test-error"));
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Internal server error");
});
});
describe("serve security", () => {
test("CORS headers present on responses", async () => {
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
const res2 = await app.fetch(
new Request("http://localhost/healthz", {
headers: { Origin: "http://localhost:5173" },
}),
);
expect(res2.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173");
});
test("POST with body > 1MB → 413", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const largeBody = "x".repeat(1_048_577);
const res = await fetch("/api/cas", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(largeBody.length),
},
body: largeBody,
});
expect(res.status).toBe(413);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Payload too large");
});
});
describe("serve CAS round-trip", () => {
const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`;
@@ -1,66 +1,49 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
import { END } from "@uncaged/workflow-runtime";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists } from "../src/fs-utils.js";
import { resolveThreadRecord } from "../src/thread-scan.js";
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";
export const descriptor = {
const threeRoleBundleSource = `export const descriptor = {
description: "fork-cli",
roles: {
planner: { description: "planner", schema: {} },
coder: { description: "coder", schema: {} },
reviewer: { description: "reviewer", schema: {} },
},
graph: { edges: [] },
};
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" };
};
`;
async function countDataJsonlLines(dataPath: string): Promise<number> {
try {
const text = await readFile(dataPath, "utf8");
return text
.trim()
.split("\n")
.filter((l) => l !== "").length;
} catch {
return 0;
}
}
async function waitUntilMinDataLines(dataPath: string, minLines: number): Promise<void> {
for (let attempt = 0; attempt < 120; attempt++) {
if ((await countDataJsonlLines(dataPath)) >= minLines) {
return;
}
await new Promise((r) => setTimeout(r, 25));
}
}
async function waitUntilRunningAbsent(runningPath: string): Promise<void> {
for (let attempt = 0; attempt < 120; attempt++) {
if (!(await pathExists(runningPath))) {
@@ -70,6 +53,41 @@ async function waitUntilRunningAbsent(runningPath: string): Promise<void> {
}
}
async function waitUntilThreadCompletes(storageRoot: string, threadId: string): Promise<void> {
for (let attempt = 0; attempt < 120; attempt++) {
const row = await resolveThreadRecord(storageRoot, threadId);
if (row?.source === "history") {
return;
}
await new Promise((r) => setTimeout(r, 25));
}
}
async function listMeaningfulRoleContents(
storageRoot: string,
threadId: string,
): Promise<Array<{ role: string; content: string }>> {
const row = await resolveThreadRecord(storageRoot, threadId);
if (row === null) {
return [];
}
const cas = createCasStore(getGlobalCasDir(storageRoot));
const frames = await walkStateFramesNewestFirst(cas, row.head);
const chronological = [...frames].reverse();
const out: Array<{ role: string; content: string }> = [];
for (const fr of chronological) {
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
continue;
}
const content = await getContentMerklePayload(cas, fr.payload.content);
out.push({
role: fr.payload.role,
content: content ?? "",
});
}
return out;
}
describe("cli fork", () => {
let prevEnv: string | undefined;
let storageRoot: string;
@@ -109,10 +127,12 @@ describe("cli fork", () => {
return;
}
const sourceId = ran.value.threadId;
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
await waitUntilRunningAbsent(sourceRunning);
await waitUntilMinDataLines(sourceData, 5);
await waitUntilThreadCompletes(storageRoot, sourceId);
const histBefore = await resolveThreadRecord(storageRoot, sourceId);
expect(histBefore?.source).toBe("history");
const forked = await cmdFork(storageRoot, sourceId, "planner");
expect(forked.ok).toBe(true);
@@ -120,25 +140,18 @@ describe("cli fork", () => {
return;
}
const newId = forked.value.threadId;
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
await waitUntilRunningAbsent(newRunning);
await waitUntilMinDataLines(newData, 5);
await waitUntilThreadCompletes(storageRoot, newId);
const text = await readFile(newData, "utf8");
const lines = text
.trim()
.split("\n")
.filter((l) => l !== "");
expect(lines.length).toBe(5);
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
expect(start.threadId).toBe(newId);
expect(start.forkFrom).toEqual({ threadId: sourceId });
const forkHist = await resolveThreadRecord(storageRoot, newId);
expect(forkHist?.source).toBe("history");
expect(forkHist?.start).toBe(histBefore?.start);
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
expect(lastRoleLine.role).toBe("reviewer");
const cas = createCasStore(getGlobalCasDir(storageRoot));
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-1");
const steps = await listMeaningfulRoleContents(storageRoot, newId);
const tail = steps[steps.length - 1];
expect(tail?.role).toBe("reviewer");
expect(tail?.content).toBe("rev-1");
});
test("fork without --from-role retries last role", async () => {
@@ -160,10 +173,8 @@ describe("cli fork", () => {
return;
}
const sourceId = ran.value.threadId;
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
await waitUntilRunningAbsent(sourceRunning);
await waitUntilMinDataLines(sourceData, 5);
await waitUntilRunningAbsent(join(storageRoot, "logs", hash, `${sourceId}.running`));
await waitUntilThreadCompletes(storageRoot, sourceId);
const forked = await cmdFork(storageRoot, sourceId, null);
expect(forked.ok).toBe(true);
@@ -171,26 +182,17 @@ describe("cli fork", () => {
return;
}
const newId = forked.value.threadId;
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
await waitUntilRunningAbsent(newRunning);
await waitUntilMinDataLines(newData, 5);
await waitUntilRunningAbsent(join(storageRoot, "logs", hash, `${newId}.running`));
await waitUntilThreadCompletes(storageRoot, newId);
const text = await readFile(newData, "utf8");
const lines = text
.trim()
.split("\n")
.filter((l) => l !== "");
expect(lines.length).toBe(5);
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
expect(replayCoder.role).toBe("coder");
const cas = createCasStore(getGlobalCasDir(storageRoot));
expect(await getContentMerklePayload(cas, String(replayCoder.contentHash))).toBe("c1");
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
expect(lastRoleLine.role).toBe("reviewer");
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-2");
const steps = await listMeaningfulRoleContents(storageRoot, newId);
expect(steps.length).toBeGreaterThanOrEqual(3);
const coderReplay = steps[steps.length - 2];
expect(coderReplay?.role).toBe("coder");
expect(coderReplay?.content).toBe("c1");
const tail = steps[steps.length - 1];
expect(tail?.role).toBe("reviewer");
expect(tail?.content).toBe("rev-2");
});
test("fork rejects unknown role with available names", async () => {
@@ -211,10 +213,10 @@ describe("cli fork", () => {
return;
}
const sourceId = ran.value.threadId;
const sourceData = join(storageRoot, "logs", added.value.hash, `${sourceId}.data.jsonl`);
const sourceRunning = join(storageRoot, "logs", added.value.hash, `${sourceId}.running`);
await waitUntilRunningAbsent(sourceRunning);
await waitUntilMinDataLines(sourceData, 5);
await waitUntilRunningAbsent(
join(storageRoot, "logs", added.value.hash, `${sourceId}.running`),
);
await waitUntilThreadCompletes(storageRoot, sourceId);
const bad = await cmdFork(storageRoot, sourceId, "ghost-role");
expect(bad.ok).toBe(false);
@@ -1,48 +1,17 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
createCasStore,
garbageCollectCas,
getGlobalCasDir,
putContentMerkleNode,
} from "@uncaged/workflow";
import { createCasStore, putStartNode } from "@uncaged/workflow-cas";
import { garbageCollectCas, getBundleDir, upsertThreadEntry } from "@uncaged/workflow-execute";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { cmdThreadRemove } from "../src/commands/thread/index.js";
import { pathExists } from "../src/fs-utils.js";
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
async function writeDemoDataJsonl(params: {
path: string;
threadId: string;
bundleHash: string;
cas: ReturnType<typeof createCasStore>;
activeHash: string;
}): Promise<void> {
const bodyHash = await putContentMerkleNode(params.cas, "p");
const text = [
JSON.stringify({
name: "demo",
hash: params.bundleHash,
threadId: params.threadId,
parameters: { prompt: "hi", options: { maxRounds: 5 } },
timestamp: 100,
}),
JSON.stringify({
role: "planner",
contentHash: bodyHash,
meta: {},
refs: [params.activeHash, bodyHash],
timestamp: 101,
}),
"",
].join("\n");
await writeFile(params.path, text, "utf8");
}
describe("gc cli and garbageCollectCas", () => {
let prevEnv: string | undefined;
let storageRoot: string;
@@ -62,22 +31,30 @@ describe("gc cli and garbageCollectCas", () => {
await rm(storageRoot, { recursive: true, force: true });
});
test("garbageCollectCas keeps CAS entries referenced by thread refs", async () => {
test("garbageCollectCas keeps CAS entries reachable from threads.json roots", async () => {
const bundleHash = "C9NMV6V2TQT81";
const threadId = "01AAA1111111111111111111";
const logsDir = join(storageRoot, "logs", bundleHash);
await mkdir(logsDir, { recursive: true });
const bundleDir = getBundleDir(storageRoot, bundleHash);
await mkdir(bundleDir, { recursive: true });
const cas = createCasStore(getGlobalCasDir(storageRoot));
const activeHash = await cas.put("active-blob");
const orphanHash = await cas.put("orphan-blob");
await writeDemoDataJsonl({
path: join(logsDir, `${threadId}.data.jsonl`),
threadId,
bundleHash,
const promptHash = await cas.put("prompt-text");
const startHash = await putStartNode(
cas,
activeHash,
{
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
await upsertThreadEntry(bundleDir, threadId, {
head: startHash,
start: startHash,
updatedAt: 100,
});
const gc = await garbageCollectCas(storageRoot);
@@ -85,12 +62,12 @@ describe("gc cli and garbageCollectCas", () => {
if (!gc.ok) {
return;
}
expect(gc.value.scannedThreads).toBe(1);
expect(gc.value.activeRefs).toBe(2);
expect(gc.value.scannedThreads).toBe(2);
expect(gc.value.deletedEntries).toBe(1);
expect(gc.value.deletedHashes).toEqual([orphanHash]);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(true);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${promptHash}.txt`))).toBe(true);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${startHash}.txt`))).toBe(true);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
});
@@ -113,19 +90,27 @@ describe("gc cli and garbageCollectCas", () => {
test("cli gc prints stats", async () => {
const bundleHash = "C9NMV6V2TQT81";
const threadId = "01BBB2222222222222222222";
const logsDir = join(storageRoot, "logs", bundleHash);
await mkdir(logsDir, { recursive: true });
const bundleDir = getBundleDir(storageRoot, bundleHash);
await mkdir(bundleDir, { recursive: true });
const cas = createCasStore(getGlobalCasDir(storageRoot));
const activeHash = await cas.put("keep-me");
const promptHash = await cas.put("prompt-text");
const startHash = await putStartNode(
cas,
{
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
await cas.put("drop-me");
await writeDemoDataJsonl({
path: join(logsDir, `${threadId}.data.jsonl`),
threadId,
bundleHash,
cas,
activeHash,
await upsertThreadEntry(bundleDir, threadId, {
head: startHash,
start: startHash,
updatedAt: 100,
});
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
@@ -134,23 +119,32 @@ describe("gc cli and garbageCollectCas", () => {
encoding: "utf8",
});
expect(proc.status).toBe(0);
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
expect(String(proc.stdout).trim()).toBe("scanned 2 threads, 2 active refs, deleted 1 entries");
});
test("thread rm triggers gc so unreferenced CAS is removed", async () => {
const bundleHash = "C9NMV6V2TQT81";
const threadId = "01CCC3333333333333333333";
const logsDir = join(storageRoot, "logs", bundleHash);
await mkdir(logsDir, { recursive: true });
const bundleDir = getBundleDir(storageRoot, bundleHash);
await mkdir(bundleDir, { recursive: true });
const cas = createCasStore(getGlobalCasDir(storageRoot));
const activeHash = await cas.put("pinned-by-ref");
await writeDemoDataJsonl({
path: join(logsDir, `${threadId}.data.jsonl`),
threadId,
bundleHash,
const promptHash = await cas.put("prompt-text");
const startHash = await putStartNode(
cas,
activeHash,
{
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
await upsertThreadEntry(bundleDir, threadId, {
head: startHash,
start: startHash,
updatedAt: 100,
});
const orphanHash = await cas.put("orphan-after-rm");
@@ -160,6 +154,6 @@ describe("gc cli and garbageCollectCas", () => {
expect(removed.ok).toBe(true);
expect(await pathExists(orphanPath)).toBe(false);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(false);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${promptHash}.txt`))).toBe(false);
});
});
@@ -58,6 +58,11 @@ describe("--help flag on groups", () => {
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
expect(code).toBe(0);
});
test("setup --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["setup", "--help"]);
expect(code).toBe(0);
});
});
describe("getSkillTopics", () => {
@@ -90,6 +95,8 @@ describe("formatCliUsage", () => {
expect(u).toContain("Thread execution:");
expect(u).toContain("Content-addressable storage:");
expect(u).toContain("Development:");
expect(u).toContain("Configuration:");
expect(u).toContain("setup [--provider <name>]");
expect(u).toContain("Shortcuts:");
expect(u).toContain("Reference:");
expect(u).toContain("skill [topic]");
@@ -128,6 +135,7 @@ describe("formatSkillTopic('cli')", () => {
expect(doc).toContain("### thread");
expect(doc).toContain("### cas");
expect(doc).toContain("### init");
expect(doc).toContain("### setup");
expect(doc).toContain("### Top-level shortcuts");
});
@@ -50,7 +50,6 @@ describe("init template", () => {
dependencies: Record<string, string>;
};
expect(pkg.type).toBe("module");
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
expect(pkg.dependencies.zod).toBeDefined();
expect(pkg.name).toContain("review-pr");
@@ -65,6 +64,7 @@ describe("init template", () => {
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
expect(moder).not.toContain("export default");
expect(moder).toContain("ModeratorTable");
});
test("finds workspace walking up from nested cwd", async () => {
@@ -38,15 +38,23 @@ describe("init workspace", () => {
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
workspaces: string[];
scripts: { bundle: string };
};
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
expect(rootPkg.scripts.bundle).toBe("bun run scripts/bundle.ts");
expect(await pathExists(join(root, "scripts", "bundle.ts"))).toBe(true);
const bundleSrc = await readFile(join(root, "scripts", "bundle.ts"), "utf8");
expect(bundleSrc).toContain("Bun.build");
expect(bundleSrc).toContain("-entry.ts");
expect(bundleSrc).toContain("distDir");
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
type: string;
dependencies: Record<string, string>;
};
expect(wfPkg.type).toBe("module");
expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined();
expect(wfPkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
expect(wfPkg.dependencies.zod).toBeDefined();
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
@@ -82,8 +90,8 @@ describe("init workspace", () => {
for (const term of [
"RoleDefinition",
"WorkflowDefinition",
"Moderator",
"AgentFn",
"ModeratorTable",
"AdapterFn",
"ExtractFn",
"RoleMeta",
]) {
@@ -117,9 +125,6 @@ describe("init workspace", () => {
});
test("errors on invalid workspace name", async () => {
const slash = await cmdInitWorkspace(parent, "a/b");
expect(slash.ok).toBe(false);
const dots = await cmdInitWorkspace(parent, "..");
expect(dots.ok).toBe(false);
@@ -127,6 +132,14 @@ describe("init workspace", () => {
expect(empty.ok).toBe(false);
});
test("accepts nested path as workspace name", async () => {
const nested = await cmdInitWorkspace(parent, "a/b");
expect(nested.ok).toBe(true);
if (nested.ok) {
expect(nested.value.rootPath).toContain("a/b");
}
});
test("usage lists init subcommands", () => {
const u = formatCliUsage();
expect(u).toContain("init workspace <name>");
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
formatLiveDebugLine,
formatLiveTimeLabel,
LIVE_CONTENT_MAX_LINES,
type LiveRoleRow,
renderLiveRoleStepLines,
} from "../src/commands/thread/index.js";
import { parseLiveArgv } from "../src/live-argv.js";
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
describe("live helpers", () => {
test("formatLiveTimeLabel pads HH:MM:SS", () => {
const label = formatLiveTimeLabel(new Date("2024-06-01T09:08:07.000Z").getTime());
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
test("formatLiveDebugLine flattens newlines in message", () => {
const line = formatLiveDebugLine(0, "TAG1", "a\nb");
expect(line).toContain("[TAG1]");
expect(line).toContain("a b");
expect(line).not.toContain("\n");
});
test("renderLiveRoleStepLines truncates content to LIVE_CONTENT_MAX_LINES", () => {
const lines = Array.from({ length: LIVE_CONTENT_MAX_LINES + 3 }, (_, i) => `L${i + 1}`);
const row: LiveRoleRow = {
role: "r",
content: lines.join("\n"),
meta: { k: "v" },
timestamp: 0,
};
const out = renderLiveRoleStepLines(row, "r");
const body = out.filter((l) => l.startsWith(" L"));
expect(body.length).toBe(LIVE_CONTENT_MAX_LINES);
expect(out.some((l) => l.includes("more line"))).toBe(true);
expect(out.some((l) => l.startsWith(" meta: "))).toBe(true);
});
});
describe("parseLiveArgv", () => {
test("parses thread id and flags in any order", () => {
const a = parseLiveArgv(["01ABC", "--debug", "--role", "planner"]);
expect(a.ok).toBe(true);
if (a.ok) {
expect(a.value.threadId).toBe("01ABC");
expect(a.value.latest).toBe(false);
expect(a.value.debug).toBe(true);
expect(a.value.role).toBe("planner");
}
const b = parseLiveArgv(["--latest", "--role", "x"]);
expect(b.ok).toBe(true);
if (b.ok) {
expect(b.value.latest).toBe(true);
expect(b.value.threadId).toBe(null);
expect(b.value.role).toBe("x");
}
});
test("rejects --latest with thread id", () => {
const r = parseLiveArgv(["--latest", "01ABC"]);
expect(r.ok).toBe(false);
});
});
describe("live CLI", () => {
let prevEnv: string | undefined;
let storageRoot: string;
beforeEach(async () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
});
afterEach(async () => {
if (prevEnv === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
}
await rm(storageRoot, { recursive: true, force: true });
});
test("unknown thread id exits 1", () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const r = spawnSync(process.execPath, [cliEntryPath, "live", "01UNKNOWNXXXXXXXXXXXXXXXXX"], {
env,
encoding: "utf8",
});
expect(r.status).toBe(1);
expect(String(r.stderr ?? "")).toContain("thread not found");
});
});
describe("live --latest with empty storage", () => {
let prevEnv: string | undefined;
let emptyRoot: string;
beforeEach(async () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
emptyRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-empty-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = emptyRoot;
});
afterEach(async () => {
if (prevEnv === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
}
await rm(emptyRoot, { recursive: true, force: true });
});
test("exits 1 when no threads exist", () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: emptyRoot };
const r = spawnSync(process.execPath, [cliEntryPath, "live", "--latest"], {
env,
encoding: "utf8",
});
expect(r.status).toBe(1);
expect(String(r.stderr ?? "")).toContain("no threads");
});
});
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readWorkflowRegistry } from "@uncaged/workflow-register";
import { runCli } from "../src/cli-dispatch.js";
import { cmdSetup } from "../src/commands/setup/index.js";
describe("setup command (CLI mode)", () => {
let prevEnv: string | undefined;
let storageRoot: string;
beforeEach(async () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-setup-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await mkdir(storageRoot, { recursive: true });
});
afterEach(async () => {
if (prevEnv === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
}
await rm(storageRoot, { recursive: true, force: true });
});
test("writes workflow.yaml with provider, models.default, and depth defaults", async () => {
const r = await cmdSetup(storageRoot, {
provider: "dashscope",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test123",
defaultModel: "dashscope/qwen-plus",
initWorkspaceName: null,
});
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok) {
return;
}
expect(reg.value.config).not.toBeNull();
if (reg.value.config === null) {
return;
}
expect(reg.value.config.providers.dashscope).toEqual({
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test123",
});
expect(reg.value.config.models.default).toBe("dashscope/qwen-plus");
expect(reg.value.config.maxDepth).toBe(3);
expect(reg.value.config.supervisorInterval).toBe(3);
const raw = await readFile(join(storageRoot, "workflow.yaml"), "utf8");
expect(raw).toContain("dashscope");
expect(raw).toContain("qwen-plus");
});
test("idempotent: second run updates apiKey and preserves workflows", async () => {
const initialYaml = `config:
maxDepth: 7
supervisorInterval: 2
providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
apiKey: sk-old
models:
default: dashscope/qwen-plus
workflows:
keep-me:
hash: "0000000000000"
timestamp: 1
history: []
`;
await writeFile(join(storageRoot, "workflow.yaml"), initialYaml, "utf8");
const r2 = await cmdSetup(storageRoot, {
provider: "dashscope",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-newkey",
defaultModel: "dashscope/qwen-plus",
initWorkspaceName: null,
});
expect(r2.ok).toBe(true);
if (!r2.ok) {
return;
}
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok || reg.value.config === null) {
return;
}
expect(reg.value.config.providers.dashscope.apiKey).toBe("sk-newkey");
expect(reg.value.config.maxDepth).toBe(7);
expect(reg.value.config.supervisorInterval).toBe(2);
expect(reg.value.workflows["keep-me"]).toBeDefined();
if (reg.value.workflows["keep-me"] === undefined) {
return;
}
expect(reg.value.workflows["keep-me"].hash).toBe("0000000000000");
});
test("runCli setup dispatches with flags and exits 0", async () => {
const code = await runCli(storageRoot, [
"setup",
"--provider",
"openai",
"--base-url",
"https://api.openai.com/v1",
"--api-key",
"sk-test",
"--default-model",
"openai/gpt-4o",
]);
expect(code).toBe(0);
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok || reg.value.config === null) {
return;
}
expect(reg.value.config.providers.openai.apiKey).toBe("sk-test");
expect(reg.value.config.models.default).toBe("openai/gpt-4o");
});
});
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
describe("resolveWorkflowStorageRoot", () => {
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { getGlobalCasDir } from "@uncaged/workflow";
import { getBundleDir, readThreadsIndex } from "@uncaged/workflow-execute";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { cmdCasPut } from "../src/commands/cas/index.js";
import {
cmdKill,
@@ -18,12 +19,10 @@ import {
} from "../src/commands/thread/index.js";
import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
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";
`;
const threadFixtureDescriptor = `export const descriptor = {
description: "thread-cli",
roles: {
@@ -34,29 +33,28 @@ const threadFixtureDescriptor = `export const descriptor = {
only: { description: "only", schema: {} },
noop: { description: "noop", schema: {} },
},
graph: { edges: [] },
};
`;
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" };
};
@@ -65,70 +63,54 @@ 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) {
await new Promise((r) => setTimeout(r, 600));
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");
await new Promise((r) => setTimeout(r, 10000));
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" };
};
`;
async function countDataJsonlLines(dataPath: string): Promise<number> {
try {
const text = await readFile(dataPath, "utf8");
return text
.trim()
.split("\n")
.filter((l) => l !== "").length;
} catch {
return 0;
}
}
async function waitUntilMinDataLines(
dataPath: string,
minLines: number,
maxAttempts: number,
): Promise<void> {
async function waitUntilRunningFileAbsent(runningPath: string, maxAttempts: number): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if ((await countDataJsonlLines(dataPath)) >= minLines) {
if (!(await pathExists(runningPath))) {
return;
}
await new Promise((r) => setTimeout(r, 25));
}
}
async function waitUntilRunningFileAbsent(runningPath: string, maxAttempts: number): Promise<void> {
async function waitUntilPredicate(
predicate: () => Promise<boolean>,
maxAttempts: number,
): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (!(await pathExists(runningPath))) {
if (await predicate()) {
return;
}
await new Promise((r) => setTimeout(r, 25));
@@ -190,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) {
@@ -197,11 +182,18 @@ describe("cli thread commands", () => {
}
expect(shown.value.includes('"threadId"')).toBe(true);
const parsed = JSON.parse(shown.value) as Record<string, unknown>;
expect(parsed.parentState).toBeNull();
const parsedSteps = parsed.steps as Array<Record<string, unknown>>;
for (const step of parsedSteps) {
expect(step).toHaveProperty("childThread");
expect(step.childThread).toBeNull();
}
const removed = await cmdThreadRemove(storageRoot, threadId);
expect(removed.ok).toBe(true);
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
expect(await pathExists(dataPath)).toBe(false);
expect(await resolveThreadRecord(storageRoot, threadId)).toBeNull();
});
test("thread rm runs GC and removes CAS blobs not referenced by any remaining thread", async () => {
@@ -234,9 +226,9 @@ describe("cli thread commands", () => {
threads = await cmdThreads(storageRoot, []);
}
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
const runningPath = join(dirname(dataPath), `${threadId}.running`);
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 120);
expect((await resolveThreadRecord(storageRoot, threadId))?.source).toBe("history");
const put = await cmdCasPut(storageRoot, "keep-after-thread-rm");
expect(put.ok).toBe(true);
@@ -317,30 +309,31 @@ describe("cli thread commands", () => {
}
const threadId = ran.value.threadId;
const killBundleDir = getBundleDir(storageRoot, added.value.hash);
await new Promise((r) => setTimeout(r, 50));
await waitUntilPredicate(async () => {
const idx = await readThreadsIndex(killBundleDir);
const ent = idx[threadId];
return ent !== undefined && ent.head !== ent.start;
}, 80);
const killed = await cmdKill(storageRoot, threadId);
expect(killed.ok).toBe(true);
await new Promise((r) => setTimeout(r, 900));
await waitUntilPredicate(async () => {
return (await resolveThreadRecord(storageRoot, threadId))?.source === "history";
}, 120);
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
const text = await readFile(dataPath, "utf8");
const lines = text
.trim()
.split("\n")
.filter((l) => l !== "");
expect(lines.length).toBe(3);
expect((await resolveThreadRecord(storageRoot, threadId))?.source).toBe("history");
const runningPath = join(dirname(dataPath), `${threadId}.running`);
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
expect(await pathExists(runningPath)).toBe(false);
});
test("pause stops between yields and resume completes thread", async () => {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const srcDir = join(storageRoot, "src");
await mkdir(srcDir, { recursive: true });
const bundlePath = join(srcDir, "demo.esm.js");
await writeFile(bundlePath, pauseResumeBundleSource, "utf8");
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
@@ -356,24 +349,33 @@ describe("cli thread commands", () => {
}
const threadId = ran.value.threadId;
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
const bundleDir = getBundleDir(storageRoot, added.value.hash);
await waitUntilMinDataLines(dataPath, 2, 80);
expect(await countDataJsonlLines(dataPath)).toBe(2);
await waitUntilPredicate(async () => {
const idx = await readThreadsIndex(bundleDir);
const ent = idx[threadId];
return ent !== undefined && ent.head !== ent.start;
}, 80);
const idxBeforePause = await readThreadsIndex(bundleDir);
const headAtPause = idxBeforePause[threadId]?.head;
const paused = await cmdPause(storageRoot, threadId);
expect(paused.ok).toBe(true);
await new Promise((r) => setTimeout(r, 400));
expect(await countDataJsonlLines(dataPath)).toBe(2);
const idxPaused = await readThreadsIndex(bundleDir);
expect(idxPaused[threadId]?.head).toBe(headAtPause);
const resumed = await cmdResume(storageRoot, threadId);
expect(resumed.ok).toBe(true);
await waitUntilMinDataLines(dataPath, 4, 120);
expect(await countDataJsonlLines(dataPath)).toBe(4);
await waitUntilPredicate(async () => {
const row = await resolveThreadRecord(storageRoot, threadId);
return row?.source === "history";
}, 120);
const runningPath = join(dirname(dataPath), `${threadId}.running`);
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 100);
expect(await pathExists(runningPath)).toBe(false);
});
@@ -397,8 +399,7 @@ describe("cli thread commands", () => {
}
const threadId = ran.value.threadId;
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
const runningPath = join(dirname(dataPath), `${threadId}.running`);
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 100);
expect(await pathExists(runningPath)).toBe(false);
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-gateway": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-execute": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"hono": "^4.12.18",
"yaml": "^2.8.4"
},
"scripts": {
"test": "bun test"
},
"publishConfig": {
"access": "public"
}
}
+51
View File
@@ -0,0 +1,51 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-cas':
specifier: workspace:*
version: link:../workflow-cas
'@uncaged/workflow-execute':
specifier: workspace:*
version: link:../workflow-execute
'@uncaged/workflow-protocol':
specifier: workspace:*
version: link:../workflow-protocol
'@uncaged/workflow-register':
specifier: workspace:*
version: link:../workflow-register
'@uncaged/workflow-runtime':
specifier: workspace:*
version: link:../workflow-runtime
'@uncaged/workflow-util':
specifier: workspace:*
version: link:../workflow-util
hono:
specifier: ^4.12.18
version: 4.12.18
yaml:
specifier: ^2.8.4
version: 2.8.4
packages:
hono@4.12.18:
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
engines: {node: '>=16.9.0'}
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots:
hono@4.12.18: {}
yaml@2.8.4: {}
@@ -1,7 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists } from "./fs-utils.js";
@@ -3,15 +3,13 @@ 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 { dispatchSetup } from "./commands/setup/index.js";
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
export { getCommandRegistry } from "./cli-registry.js";
function dispatchGroup(
tableName: string,
table: Record<string, CommandEntry>,
@@ -69,10 +67,11 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
thread: dispatchThread,
cas: dispatchCas,
init: dispatchInit,
setup: dispatchSetup,
skill: dispatchSkill,
run: dispatchRun,
live: dispatchLive,
serve: dispatchServe,
connect: dispatchConnect,
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
@@ -5,6 +5,15 @@ import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
const SETUP_USAGE_COMMANDS = [
{
name: "",
args: "[--provider <name>] [--base-url <url>] [--api-key <key>] [--default-model <provider/model>] [--init-workspace <name>]",
description:
"Configure workflow.yaml LLM providers and default model (interactive when no flags)",
},
] as const;
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
return [
{
@@ -39,6 +48,10 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
description: e.description,
})),
},
{
name: "setup",
commands: [...SETUP_USAGE_COMMANDS],
},
];
}
@@ -12,6 +12,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
thread: "Thread execution:",
cas: "Content-addressable storage:",
init: "Development:",
setup: "Configuration:",
};
export function formatUsageCommandLines(
@@ -38,9 +39,10 @@ export function formatCliUsage(
}
lines.push(sectionTitle);
const rows = group.commands.map((cmd) => {
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
const args = cmd.args ? ` ${cmd.args}` : "";
return {
prefix: `${group.name} ${cmd.name}${args}`,
prefix: `${group.name}${namePart}${args}`,
description: cmd.description,
};
});
@@ -57,12 +59,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",
},
]),
);
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
import { runCli } from "./cli-dispatch.js";
import { resolveWorkflowStorageRoot } from "./storage-env.js";
const argv = process.argv.slice(2);
const storageRoot = resolveWorkflowStorageRoot();
const code = await runCli(storageRoot, argv);
process.exit(code);
@@ -0,0 +1,6 @@
import { type GcResult, garbageCollectCas } from "@uncaged/workflow-execute";
import type { Result } from "@uncaged/workflow-protocol";
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
return garbageCollectCas(storageRoot);
}
@@ -1,4 +1,6 @@
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
import { createCasStore } from "@uncaged/workflow-cas";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
export async function cmdCasGet(
storageRoot: string,
@@ -1,4 +1,6 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
import { createCasStore } from "@uncaged/workflow-cas";
import { ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
@@ -1,4 +1,6 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
import { createCasStore } from "@uncaged/workflow-cas";
import { ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
export async function cmdCasPut(
storageRoot: string,
@@ -1,4 +1,6 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
import { createCasStore } from "@uncaged/workflow-cas";
import { ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
@@ -0,0 +1,60 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { createCasRoutes } from "./routes-cas.js";
import { createLiveRoutes } from "./routes-live.js";
import { createThreadRoutes } from "./routes-thread.js";
import { createWorkflowRoutes } from "./routes-workflow.js";
const MAX_BODY_SIZE = 1_048_576; // 1 MB
export function createApp(storageRoot: string, clientToken: string | null): Hono {
const app = new Hono();
app.onError((_err, c) => {
return c.json({ error: "Internal server error" }, 500);
});
app.use(
"*",
cors({
origin: [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:7860",
"http://127.0.0.1:7860",
],
}),
);
app.use("*", async (c, next) => {
if (c.req.method === "POST") {
const contentLength = c.req.header("content-length");
if (contentLength !== undefined && Number(contentLength) > MAX_BODY_SIZE) {
return c.json({ error: "Payload too large" }, 413);
}
}
await next();
});
// ── Client token auth (skip healthz) ───────────────────────────────
if (clientToken !== null) {
app.use("/api/*", async (c, next) => {
const token = c.req.header("X-Client-Token");
if (token !== clientToken) {
return c.json({ error: "unauthorized" }, 401);
}
await next();
});
}
app.get("/healthz", (c) => c.json({ ok: true }));
app.get("/api/healthz", (c) => c.json({ ok: true }));
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
app.route("/api/threads", createThreadRoutes(storageRoot));
app.route("/api/threads", createLiveRoutes(storageRoot));
app.route("/api/cas", createCasRoutes(storageRoot));
return app;
}
@@ -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;
}
@@ -0,0 +1,54 @@
import { printCliLine } from "../../cli-output.js";
export async function registerWithGateway(
gatewayUrl: string,
name: string,
localUrl: string,
secret: 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: localUrl, secret, clientToken }),
});
if (!resp.ok) {
const body = await resp.text();
printCliLine(`gateway registration failed: ${resp.status} ${body}`);
return false;
}
return true;
} catch (e) {
printCliLine(`gateway registration error: ${e}`);
return false;
}
}
export async function unregisterFromGateway(
gatewayUrl: string,
name: string,
secret: string,
): Promise<void> {
try {
await fetch(`${gatewayUrl}/api/gateway/register/${name}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${secret}` },
});
} catch {
// Best effort — process is exiting
}
}
export function startHeartbeat(
gatewayUrl: string,
name: string,
localUrl: string,
secret: string,
clientToken: string,
intervalMs: number,
): ReturnType<typeof setInterval> {
return setInterval(() => {
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
}, intervalMs);
}
@@ -0,0 +1,2 @@
export { dispatchConnect } from "./connect.js";
export type { ConnectOptions } from "./types.js";
@@ -1,19 +1,19 @@
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
import { createCasStore } from "@uncaged/workflow-cas";
import { garbageCollectCas } from "@uncaged/workflow-execute";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { Hono } from "hono";
export function createCasRoutes(storageRoot: string): Hono {
const app = new Hono();
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
app.get("/", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hashes = await cas.list();
return c.json({ hashes });
});
app.get("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const content = await cas.get(c.req.param("hash"));
if (content === null) {
return c.json({ error: "not found" }, 404);
@@ -22,19 +22,20 @@ export function createCasRoutes(storageRoot: string): Hono {
});
app.post("/", async (c) => {
const body = await c.req.json<{ content: string }>();
let body: { content: string };
try {
body = (await c.req.json()) as { content: string };
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
if (typeof body.content !== "string") {
return c.json({ error: "content field required" }, 400);
}
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = await cas.put(body.content);
return c.json({ hash }, 201);
});
app.delete("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = c.req.param("hash");
const content = await cas.get(hash);
if (content === null) {
@@ -0,0 +1,374 @@
import { existsSync, statSync, watch } from "node:fs";
import { join } from "node:path";
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import {
FORK_BRANCH_ROLE,
readThreadsIndex,
type ThreadIndex,
walkStateFramesNewestFirst,
} from "@uncaged/workflow-execute";
import { END } from "@uncaged/workflow-runtime";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { resolveThreadRecord } from "../../thread-scan.js";
type PumpState = {
contentOffset: number;
carry: string;
};
function fileSize(path: string): number {
try {
return statSync(path).size;
} catch {
return 0;
}
}
async function readNewBytes(path: string, state: PumpState): Promise<string | null> {
const size = fileSize(path);
if (size < state.contentOffset) {
state.contentOffset = 0;
state.carry = "";
}
if (size <= state.contentOffset) {
return null;
}
const blob = Bun.file(path).slice(state.contentOffset, size);
const chunk = await blob.text();
state.contentOffset = size;
return chunk;
}
function parseJsonLine(line: string): unknown {
try {
return JSON.parse(line) as unknown;
} catch {
return { raw: line };
}
}
function parseNewLines(chunk: string, state: PumpState): string[] {
state.carry += chunk;
const parts = state.carry.split("\n");
state.carry = parts.pop() ?? "";
const lines: string[] = [];
for (const line of parts) {
const trimmed = line.trim();
if (trimmed !== "") {
lines.push(trimmed);
}
}
return lines;
}
type CasSseState = {
printedHashes: Set<string>;
lastHead: string | null;
completionEmitted: boolean;
};
type LiveSseStream = {
writeSSE: (opts: { event: string; data: string; id: string }) => Promise<void>;
};
function completionFromEndMeta(meta: Record<string, unknown>): {
returnCode: number;
summary: string;
} | null {
const returnCode = meta.returnCode;
const summary = meta.summary;
if (typeof returnCode !== "number" || typeof summary !== "string") {
return null;
}
return { returnCode, summary };
}
async function emitRecordsForHead(params: {
storageRoot: string;
bundleDir: string;
threadId: string;
headHash: string;
sseState: CasSseState;
stream: LiveSseStream;
eventId: { n: number };
}): Promise<boolean> {
const cas = createCasStore(getGlobalCasDir(params.storageRoot));
const frames = await walkStateFramesNewestFirst(cas, params.headHash);
const chronological = [...frames].reverse();
for (const fr of chronological) {
if (params.sseState.printedHashes.has(fr.hash)) {
continue;
}
params.sseState.printedHashes.add(fr.hash);
const role = fr.payload.role;
if (role === FORK_BRANCH_ROLE) {
continue;
}
if (role === END) {
const wf = completionFromEndMeta(fr.payload.meta);
if (wf !== null) {
params.eventId.n++;
await params.stream.writeSSE({
event: "record",
data: JSON.stringify({
type: "workflow-result",
returnCode: wf.returnCode,
content: wf.summary,
timestamp: null,
}),
id: String(params.eventId.n),
});
return true;
}
continue;
}
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
const content =
payloadText !== null
? payloadText
: `(content not in CAS; contentHash=${fr.payload.content})`;
params.eventId.n++;
await params.stream.writeSSE({
event: "record",
data: JSON.stringify({
type: "role",
role: fr.payload.role,
contentHash: fr.payload.content,
content,
meta: fr.payload.meta,
timestamp: fr.payload.timestamp,
}),
id: String(params.eventId.n),
});
}
return false;
}
async function pumpThreadsJsonSse(params: {
storageRoot: string;
bundleDir: string;
threadId: string;
sseState: CasSseState;
stream: LiveSseStream;
eventId: { n: number };
}): Promise<boolean> {
let idx: ThreadIndex;
try {
idx = await readThreadsIndex(params.bundleDir);
} catch {
idx = {};
}
const active = idx[params.threadId];
if (active === undefined) {
if (params.sseState.completionEmitted) {
return false;
}
const hist = await resolveThreadRecord(params.storageRoot, params.threadId);
if (hist === null || hist.source !== "history") {
return false;
}
params.sseState.completionEmitted = true;
return await emitRecordsForHead({
storageRoot: params.storageRoot,
bundleDir: params.bundleDir,
threadId: params.threadId,
headHash: hist.head,
sseState: params.sseState,
stream: params.stream,
eventId: params.eventId,
});
}
const head = active.head;
if (params.sseState.lastHead === null) {
params.sseState.lastHead = head;
return await emitRecordsForHead({
storageRoot: params.storageRoot,
bundleDir: params.bundleDir,
threadId: params.threadId,
headHash: head,
sseState: params.sseState,
stream: params.stream,
eventId: params.eventId,
});
}
if (head !== params.sseState.lastHead) {
params.sseState.lastHead = head;
return await emitRecordsForHead({
storageRoot: params.storageRoot,
bundleDir: params.bundleDir,
threadId: params.threadId,
headHash: head,
sseState: params.sseState,
stream: params.stream,
eventId: params.eventId,
});
}
return false;
}
export function createLiveRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/:threadId/live", async (c) => {
const threadId = c.req.param("threadId");
const resolved = await resolveThreadRecord(storageRoot, threadId);
if (resolved === null) {
return c.json({ error: `thread not found: ${threadId}` }, 404);
}
const threadTarget = resolved;
const threadsJsonPath = join(threadTarget.bundleDir, "threads.json");
const infoPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.info.jsonl`);
return streamSSE(c, async (stream) => {
const infoState: PumpState = { contentOffset: 0, carry: "" };
const sseThreadState: CasSseState = {
printedHashes: new Set<string>(),
lastHead: null,
completionEmitted: false,
};
const eventId = { n: 0 };
async function pumpData(): Promise<boolean> {
const finished = await pumpThreadsJsonSse({
storageRoot,
bundleDir: threadTarget.bundleDir,
threadId,
sseState: sseThreadState,
stream,
eventId,
});
return finished;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SSE newline framing mirrors legacy pump
async function pumpInfo(): Promise<void> {
let chunk: string | null;
try {
chunk = await readNewBytes(infoPath, infoState);
} catch {
return;
}
if (chunk === null) {
return;
}
const lines = parseNewLines(chunk, infoState);
for (const line of lines) {
const record = parseJsonLine(line);
if (
typeof record === "object" &&
record !== null &&
"raw" in (record as Record<string, unknown>)
) {
continue;
}
eventId.n++;
await stream.writeSSE({
event: "info",
data: JSON.stringify(record),
id: String(eventId.n),
});
}
}
eventId.n++;
await stream.writeSSE({
event: "record",
data: JSON.stringify({
type: "thread-start",
threadId: threadTarget.threadId,
bundleHash: threadTarget.bundleHash,
head: threadTarget.head,
start: threadTarget.start,
source: threadTarget.source,
}),
id: String(eventId.n),
});
const done = await pumpData();
try {
await pumpInfo();
} catch {
// optional info file
}
if (done) {
return;
}
// If thread is not actively running, emit all records and close — don't keep SSE open
const runningPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.running`);
if (!existsSync(runningPath)) {
eventId.n++;
await stream.writeSSE({
event: "done",
data: JSON.stringify({ reason: "not-running" }),
id: String(eventId.n),
});
return;
}
const controller = new AbortController();
let completed = false;
const threadsJsonWatcher = watch(threadsJsonPath, async () => {
if (completed) {
return;
}
const finished = await pumpData();
if (finished) {
completed = true;
controller.abort();
}
});
let infoWatcher: ReturnType<typeof watch> | null = null;
try {
infoWatcher = watch(infoPath, async () => {
if (completed) {
return;
}
await pumpInfo();
});
} catch {
// info file may not exist
}
stream.onAbort(() => {
completed = true;
threadsJsonWatcher.close();
infoWatcher?.close();
});
await new Promise<void>((resolve) => {
if (completed) {
resolve();
return;
}
controller.signal.addEventListener("abort", () => resolve(), { once: true });
stream.onAbort(() => resolve());
});
threadsJsonWatcher.close();
infoWatcher?.close();
});
});
return app;
}
@@ -0,0 +1,199 @@
import { join } from "node:path";
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
import { END } from "@uncaged/workflow-runtime";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { Hono } from "hono";
import { pathExists } from "../../fs-utils.js";
import type { HistoricalThreadRow, ResolvedThreadRecord } from "../../thread-scan.js";
import {
listHistoricalThreads,
listRunningThreads,
resolveThreadListStatus,
resolveThreadRecord,
} from "../../thread-scan.js";
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
import { cmdRun } from "../thread/run.js";
async function readStartInfo(
cas: ReturnType<typeof createCasStore>,
startHash: string,
): Promise<{ name: string | null; prompt: string | null }> {
const raw = await cas.get(startHash);
if (raw === null) return { name: null, prompt: null };
const parsed = parseCasThreadNode(raw);
if (parsed === null || parsed.kind !== "start") return { name: null, prompt: null };
const name = parsed.node.payload.name;
const promptHash = parsed.node.refs[0] ?? null;
let prompt: string | null = null;
if (promptHash !== null) {
prompt = await getContentMerklePayload(cas, promptHash);
}
return { name, prompt };
}
async function buildThreadDetailRecords(
storageRoot: string,
resolved: ResolvedThreadRecord,
runningMarkerPresent: boolean,
statusRow: HistoricalThreadRow,
): Promise<unknown[]> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
const chronological = [...frames].reverse();
const { name: workflowName, prompt } = await readStartInfo(cas, resolved.start);
const status = await resolveThreadListStatus(storageRoot, statusRow, runningMarkerPresent);
const records: unknown[] = [
{
type: "thread-start",
workflow: workflowName ?? "unknown",
prompt: prompt ?? null,
threadId: resolved.threadId,
status,
timestamp: null,
},
];
for (const fr of chronological) {
if (fr.payload.role === FORK_BRANCH_ROLE) {
continue;
}
if (fr.payload.role === END) {
const returnCode = fr.payload.meta.returnCode;
const summary = fr.payload.meta.summary;
if (typeof returnCode === "number" && typeof summary === "string") {
records.push({
type: "workflow-result",
returnCode,
content: summary,
timestamp: fr.payload.timestamp,
});
}
continue;
}
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
const content =
payloadText !== null
? payloadText
: `(content not in CAS; contentHash=${fr.payload.content})`;
records.push({
type: "role",
role: fr.payload.role,
contentHash: fr.payload.content,
content,
meta: fr.payload.meta,
timestamp: fr.payload.timestamp,
});
}
return records;
}
export function createThreadRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/", async (c) => {
const nameFilter = c.req.query("workflow") ?? null;
const rows = await listHistoricalThreads(storageRoot, nameFilter);
const threads = await Promise.all(
rows.map(async (r) => {
const runningPath = join(storageRoot, "logs", r.hash, `${r.threadId}.running`);
const runningMarkerPresent = await pathExists(runningPath);
const status = await resolveThreadListStatus(storageRoot, r, runningMarkerPresent);
return {
threadId: r.threadId,
workflow: r.workflowName,
hash: r.hash,
startedAt: new Date(r.activityTs).toISOString(),
status,
};
}),
);
return c.json({ threads });
});
app.get("/running", async (c) => {
const rows = await listRunningThreads(storageRoot);
return c.json({ threads: rows });
});
app.get("/:threadId", async (c) => {
const threadId = c.req.param("threadId");
const resolved = await resolveThreadRecord(storageRoot, threadId);
if (resolved === null) {
return c.json({ error: `thread not found: ${threadId}` }, 404);
}
const runningPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.running`);
const runningMarkerPresent = await pathExists(runningPath);
const statusRow = {
threadId: resolved.threadId,
hash: resolved.bundleHash,
workflowName: null,
source: resolved.source,
activityTs: 0,
head: resolved.head,
};
const records = await buildThreadDetailRecords(
storageRoot,
resolved,
runningMarkerPresent,
statusRow,
);
return c.json({ threadId, records });
});
app.post("/", async (c) => {
let body: Record<string, unknown>;
try {
body = (await c.req.json()) as Record<string, unknown>;
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
const name = body.workflow;
const prompt = body.prompt;
if (typeof name !== "string" || typeof prompt !== "string") {
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
}
const result = await cmdRun(storageRoot, name, prompt);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ threadId: result.value.threadId }, 201);
});
app.post("/:threadId/kill", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdKill(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
app.post("/:threadId/pause", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdPause(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
app.post("/:threadId/resume", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdResume(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
return app;
}
@@ -1,9 +1,14 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { WorkflowDescriptor } from "@uncaged/workflow-protocol";
import {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
readWorkflowRegistry,
} from "@uncaged/workflow";
validateWorkflowDescriptor,
} from "@uncaged/workflow-register";
import { Hono } from "hono";
import { parse as parseYaml } from "yaml";
export function createWorkflowRoutes(storageRoot: string): Hono {
const app = new Hono();
@@ -35,7 +40,17 @@ export function createWorkflowRoutes(storageRoot: string): Hono {
if (entry === null) {
return c.json({ error: `workflow not found: ${name}` }, 404);
}
return c.json({ name, ...entry });
let descriptor: WorkflowDescriptor | null = null;
try {
const yamlPath = join(storageRoot, "bundles", `${entry.hash}.yaml`);
const yamlText = await readFile(yamlPath, "utf8");
const parsed: unknown = parseYaml(yamlText);
const validated = validateWorkflowDescriptor(parsed);
descriptor = validated.ok ? validated.value : null;
} catch {
descriptor = null;
}
return c.json({ name, ...entry, descriptor });
});
app.get("/:name/history", async (c) => {
@@ -0,0 +1,5 @@
export type ConnectOptions = {
name: string;
gatewayUrl: string;
gatewaySecret: string;
};
@@ -0,0 +1,164 @@
import { parseWsRequestJson, type WsResponse } from "@uncaged/workflow-gateway/ws-protocol";
import type { LogFn } from "@uncaged/workflow-util";
export type GatewayWsClientParams = {
gatewayUrl: string;
name: string;
secret: string;
appFetch: (request: Request) => Response | Promise<Response>;
log: LogFn;
};
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30_000;
export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secret: string): string {
const u = new URL(gatewayUrl);
if (u.protocol === "https:") {
u.protocol = "wss:";
} else if (u.protocol === "http:") {
u.protocol = "ws:";
}
u.pathname = "/ws/connect";
u.search = "";
u.searchParams.set("name", name);
u.searchParams.set("secret", secret);
return u.href;
}
function headersToRecord(h: Headers): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of h) {
out[k] = v;
}
return out;
}
async function handleGatewayMessage(
ws: WebSocket,
raw: string,
params: GatewayWsClientParams,
): Promise<void> {
const req = parseWsRequestJson(raw);
if (req === null) {
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
return;
}
const localUrl = `http://localhost${req.path}`;
const headers = new Headers(req.headers);
let resp: Response;
try {
resp = await params.appFetch(
new Request(localUrl, {
method: req.method,
headers,
body: req.body === null ? undefined : req.body,
}),
);
} catch (e) {
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
const errBody: WsResponse = {
id: req.id,
status: 502,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: "local fetch failed", detail: String(e) }),
};
ws.send(JSON.stringify(errBody));
return;
}
const bodyText = await resp.text();
const headerRecord = headersToRecord(resp.headers);
const out: WsResponse = {
id: req.id,
status: resp.status,
headers: headerRecord,
body: bodyText,
};
ws.send(JSON.stringify(out));
}
/** Maintains a reverse WebSocket to the workflow gateway; reconnects with exponential backoff. */
export function startGatewayWsClient(params: GatewayWsClientParams): () => void {
const wsUrl = buildGatewayWsConnectUrl(params.gatewayUrl, params.name, params.secret);
let socket: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
let attempt = 0;
const clearReconnectTimer = (): void => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const scheduleReconnect = (): void => {
if (stopped) {
return;
}
clearReconnectTimer();
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
attempt++;
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
reconnectTimer = setTimeout(connect, delayMs);
};
const connect = (): void => {
if (stopped) {
return;
}
clearReconnectTimer();
params.log("2XK7HM9Q", "gateway WebSocket connecting...");
try {
socket = new WebSocket(wsUrl);
} catch (e) {
params.log("7NQW4HBT", `gateway WebSocket create failed: ${String(e)}`);
scheduleReconnect();
return;
}
const ws = socket;
ws.addEventListener("open", () => {
attempt = 0;
params.log("4PWN3V82", "gateway WebSocket connected");
});
ws.addEventListener("close", (ev) => {
socket = null;
params.log(
"8QTR6ZKC",
`gateway WebSocket closed code=${String(ev.code)} reason=${ev.reason} wasClean=${String(ev.wasClean)}`,
);
if (!stopped) {
scheduleReconnect();
}
});
ws.addEventListener("error", () => {
params.log("9BWS1M7F", "gateway WebSocket error");
});
ws.addEventListener("message", (ev) => {
const data = ev.data;
if (typeof data !== "string") {
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
return;
}
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
});
});
};
connect();
return (): void => {
stopped = true;
clearReconnectTimer();
if (socket !== null && socket.readyState === WebSocket.OPEN) {
socket.close(1000, "shutdown");
}
socket = null;
};
}
@@ -1,7 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists } from "../../fs-utils.js";
@@ -6,8 +6,7 @@ export function templatePackageJson(templateName: string): string {
private: true,
type: "module",
dependencies: {
"@uncaged/workflow": "^0.1.0",
"@uncaged/workflow-runtime": "^0.1.0",
"@uncaged/workflow-runtime": "^0.3.1",
zod: "^4.0.0",
},
},
@@ -51,25 +50,19 @@ const greeterMetaSchema = z.object({
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.",
extractPrompt: "Extract the assistant's greeting as message.",
schema: greeterMetaSchema,
extractRefs: null,
};
`;
}
export function templateModeratorTs(): string {
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
return `import { END, START, type ModeratorTable } from "@uncaged/workflow-runtime";
import type { HelloTemplateMeta } from "./roles.js";
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
ctx: ModeratorContext<HelloTemplateMeta>,
) => {
if (ctx.steps.length === 0) {
return "greeter";
}
return END;
export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
`;
}
@@ -77,7 +70,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
export function templateIndexTs(): string {
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { helloTemplateModerator } from "./moderator.js";
import { helloTemplateTable } from "./moderator.js";
import {
HELLO_TEMPLATE_DESCRIPTION,
type HelloTemplateMeta,
@@ -89,14 +82,14 @@ export {
type HelloTemplateMeta,
greeterRole,
} from "./roles.js";
export { helloTemplateModerator } from "./moderator.js";
export { helloTemplateTable } from "./moderator.js";
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
description: HELLO_TEMPLATE_DESCRIPTION,
roles: {
greeter: greeterRole,
},
moderator: helloTemplateModerator,
table: helloTemplateTable,
};
`;
}
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
export function validateWorkspaceSegment(name: string): Result<void, string> {
@@ -1,11 +1,10 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { basename, join, resolve } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists } from "../../fs-utils.js";
import type { CmdInitWorkspaceSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
function rootPackageJson(workspaceName: string): string {
return `${JSON.stringify(
@@ -14,6 +13,9 @@ function rootPackageJson(workspaceName: string): string {
private: true,
type: "module",
workspaces: ["templates/*", "workflows"],
scripts: {
bundle: "bun run scripts/bundle.ts",
},
},
null,
2,
@@ -28,7 +30,7 @@ function workflowsPackageJson(): string {
private: true,
type: "module",
dependencies: {
"@uncaged/workflow": "^0.1.0",
"@uncaged/workflow-runtime": "^0.3.1",
zod: "^4.0.0",
},
},
@@ -42,7 +44,9 @@ function biomeJson(): string {
{
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
files: {
includes: ["**", "!**/node_modules", "!**/dist"],
// Exclude generated bundle script — it uses Bun globals and console that
// conflict with the workspace's Biome rules (noConsole, etc.).
includes: ["**", "!**/node_modules", "!**/dist", "!scripts/bundle.ts"],
},
formatter: {
indentWidth: 2,
@@ -85,29 +89,29 @@ function agentsMd(): string {
| | / | |
|------|----------------|------|
| **Workspace** | \`package.json\`\`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
Init \`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
## 2.
- **RoleMeta**\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
- **RoleDefinition<Meta>**\`description\`\`systemPrompt\`\`extractPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**\`description\` + \`roles\`(各角色定义)+ **Moderator**
- **Moderator**\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由
- **AgentFn**\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`
- **ExtractFn** prompt Agent 使
- **RoleDefinition<Meta>**\`description\`\`systemPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)
- **ModeratorTable** \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**
- **AdapterFn** Zod schemaRoleFn
- **ExtractFn** CAS content hash Adapter 使
**Moderator** **Agent** **Extract** **meta** step **END** \`docs/architecture.md\` 中的三阶段说明。
**ModeratorTable** **Adapter** typed meta step **END** \`docs/architecture.md\` 中的三阶段说明。
## 3.
1. ** RoleMeta** meta TypeScript Zod schema
2. ** RoleDefinition** Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`
3. ** Moderator** \`ctx.steps\`业务状态返回下一个角色名或 \`END\`
4. ** WorkflowDefinition** \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
5. **** workflow 使 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
2. ** RoleDefinition** Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`
3. ** ModeratorTable** \`START\`各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`
4. ** WorkflowDefinition** \`index\` 中导出 definition(以及必要的角色 / table 导出)。
5. **** workflow 使 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
6. **** **.esm.js** bundle使 **uncaged-workflow add**
## 4.
@@ -153,7 +157,13 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
---
workflow **RoleMeta RoleDefinitionZod Moderator bundle**
workflow **RoleMeta RoleDefinitionZod ModeratorTable bundle**
`;
}
function bunfigToml(): string {
return `[install.scopes]
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
`;
}
@@ -164,7 +174,7 @@ Local workflow development workspace (Bun monorepo).
## Layout
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
## Commands
@@ -184,32 +194,100 @@ uncaged-workflow init workspace ${workspaceName}
`;
}
function bundleTs(): string {
return [
'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");',
"",
"function isEntryFile(name: string): boolean {",
' return name.endsWith("-entry.ts");',
"}",
"",
"function entryStem(name: string): string {",
' return name.slice(0, -".ts".length);',
"}",
"",
"async function main(): Promise<void> {",
" await mkdir(distDir, { recursive: true });",
" let files: string[];",
" try {",
" files = await readdir(workflowsDir);",
" } catch {",
' console.error("bundle: missing workflows/ directory");',
" process.exitCode = 1;",
" return;",
" }",
" const entries = files.filter(isEntryFile);",
" if (entries.length === 0) {",
' console.warn("bundle: no *-entry.ts files under workflows/");',
" return;",
" }",
" for (const file of entries) {",
" const stem = entryStem(file);",
" const entryPath = join(workflowsDir, file);",
" const result = await Bun.build({",
" entrypoints: [entryPath],",
" outdir: distDir,",
' format: "esm",',
' target: "node",',
" splitting: false,",
' naming: { entry: "[name].esm.js" },',
" });",
" if (!result.success) {",
" for (const log of result.logs) {",
" console.error(log);",
" }",
` throw new Error(\`bundle failed for \${file}\`);`,
" }",
" const dts =",
` 'export { run, descriptor } from "../workflows/' + stem + '.js";\\n';`,
` await writeFile(join(distDir, \`\${stem}.d.ts\`), dts, "utf8");`,
` console.log(\`bundle: \${stem} -> dist/\${stem}.esm.js\`);`,
" }",
"}",
"",
"await main();",
"",
].join("\n");
}
export async function cmdInitWorkspace(
parentDir: string,
workspaceName: string,
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
const validated = validateWorkspaceSegment(workspaceName);
if (!validated.ok) {
return validated;
// Accept a relative/absolute path: resolve it and derive the dir name for package.json.
const resolved = resolve(parentDir, workspaceName);
const rootPath = resolved;
const dirName = basename(resolved);
if (dirName === "" || dirName === "." || dirName === "..") {
return err(`invalid workspace path: ${workspaceName}`);
}
const rootPath = join(parentDir, workspaceName);
if (await pathExists(rootPath)) {
return err(`directory already exists: ${rootPath}`);
}
await mkdir(rootPath, { recursive: false });
await mkdir(join(rootPath, "templates"), { recursive: false });
await mkdir(join(rootPath, "workflows"), { recursive: false });
await mkdir(rootPath, { recursive: true });
await mkdir(join(rootPath, "templates"), { recursive: true });
await mkdir(join(rootPath, "workflows"), { recursive: true });
await mkdir(join(rootPath, "scripts"), { recursive: true });
await Promise.all([
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
writeFile(join(rootPath, "package.json"), rootPackageJson(dirName), "utf8"),
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
writeFile(join(rootPath, "README.md"), readmeMd(dirName), "utf8"),
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
writeFile(join(rootPath, "workflows", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
]);
return ok({ rootPath });
@@ -0,0 +1,451 @@
import { existsSync } from "node:fs";
import { resolve as resolvePath } from "node:path";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { createLogger } from "@uncaged/workflow-util";
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
const setupDispatchLog = createLogger({ sink: { kind: "stderr" } });
import { loadPresetProviders } from "./preset-providers.js";
import { cmdSetup, printSetupSummary } from "./setup.js";
import type { SetupCliArgs } from "./types.js";
type OpenAiModelEntry = {
id: string;
};
type OpenAiModelsResponse = {
data: OpenAiModelEntry[];
};
function usageSetup(): string {
return [
"uncaged-workflow setup — configure workflow.yaml providers and default model",
"",
"Non-interactive (agent mode):",
" uncaged-workflow setup \\",
" --provider <name> \\",
" --base-url <url> \\",
" --api-key <key> \\",
" --default-model <provider/model> \\",
" [--init-workspace <name>]",
"",
"Interactive: run with no flags (prompts for each value).",
"",
"Storage: uses the same root as other commands (see UNCAGED_WORKFLOW_STORAGE_ROOT).",
].join("\n");
}
function requireNext(argv: string[], i: number, flag: string): Result<string, string> {
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
return err(`${flag} requires a value`);
}
return ok(next);
}
type ParsedSetup = SetupCliArgs | "interactive" | "help";
type SetupFlagField = "provider" | "baseUrl" | "apiKey" | "defaultModel" | "initWorkspaceName";
const SETUP_FLAG_TO_FIELD: Record<string, SetupFlagField> = {
"--provider": "provider",
"--base-url": "baseUrl",
"--api-key": "apiKey",
"--default-model": "defaultModel",
"--init-workspace": "initWorkspaceName",
};
function emptyFlagState(): Record<SetupFlagField, string | null> {
return {
provider: null,
baseUrl: null,
apiKey: null,
defaultModel: null,
initWorkspaceName: null,
};
}
function finalizeParsedSetup(
state: Record<SetupFlagField, string | null>,
): Result<ParsedSetup, string> {
const hasAnyFlag =
state.provider !== null ||
state.baseUrl !== null ||
state.apiKey !== null ||
state.defaultModel !== null ||
state.initWorkspaceName !== null;
if (!hasAnyFlag) {
return ok("interactive");
}
if (state.provider === null) {
return err(
"non-interactive setup requires --provider (or omit all flags for interactive mode)",
);
}
const missing: string[] = [];
if (state.baseUrl === null) {
missing.push("--base-url");
}
if (state.apiKey === null) {
missing.push("--api-key");
}
if (state.defaultModel === null) {
missing.push("--default-model");
}
if (missing.length > 0) {
return err(`missing required flag(s): ${missing.join(", ")}`);
}
const b = state.baseUrl;
const k = state.apiKey;
const m = state.defaultModel;
if (b === null || k === null || m === null) {
return err("internal: missing required flags after validation");
}
return ok({
provider: state.provider,
baseUrl: b,
apiKey: k,
defaultModel: m,
initWorkspaceName: state.initWorkspaceName,
});
}
function parseSetupArgv(argv: string[]): Result<ParsedSetup, string> {
const state = emptyFlagState();
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === undefined) {
break;
}
if (tok === "--help" || tok === "-h") {
return ok("help");
}
const field = SETUP_FLAG_TO_FIELD[tok];
if (field === undefined) {
return err(`unknown argument: ${tok}`);
}
const v = requireNext(argv, i, tok);
if (!v.ok) {
return v;
}
state[field] = v.value;
i++;
}
return finalizeParsedSetup(state);
}
async function promptLine(
rl: { question: (q: string) => Promise<string> },
label: string,
): Promise<string> {
const raw = await rl.question(label);
return raw.trim();
}
type SecretInputState = {
buf: string;
rawWasSet: boolean;
onData: (chunk: string) => void;
fulfill: (value: string) => void;
};
function isLineTerminator(c: string): boolean {
return c === "\n" || c === "\r" || c === "\u0004";
}
function handleLineTerminator(state: SecretInputState): void {
if (process.stdin.isTTY) {
process.stdin.setRawMode(state.rawWasSet);
}
process.stdin.pause();
process.stdin.removeListener("data", state.onData);
process.stdout.write("\n");
state.fulfill(state.buf.trim());
}
function handleBackspace(state: SecretInputState): void {
if (state.buf.length > 0) {
state.buf = state.buf.slice(0, -1);
process.stdout.write("\b \b");
}
}
function handleInterrupt(rawWasSet: boolean): void {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.exit(130);
}
function isBackspace(c: string): boolean {
return c === "\u007F" || c === "\b";
}
/** Process a single character in secret input. Returns "done" to stop reading. */
function processSecretChar(c: string, state: SecretInputState): "done" | "skip" | "append" {
if (isLineTerminator(c)) {
handleLineTerminator(state);
return "done";
}
if (isBackspace(c)) {
handleBackspace(state);
return "skip";
}
if (c === "\u0003") {
handleInterrupt(state.rawWasSet);
}
state.buf += c;
process.stdout.write("*");
return "append";
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
return new Promise((fulfill) => {
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf8");
const state: SecretInputState = { buf: "", rawWasSet, fulfill, onData: () => {} };
const onData = (chunk: string) => {
for (const c of chunk.toString()) {
if (processSecretChar(c, state) === "done") return;
}
};
state.onData = onData;
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<string[]> {
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
setupDispatchLog("R5KH7WM3", `GET ${url} returned ${res.status}`);
return [];
}
const body = (await res.json()) as OpenAiModelsResponse;
if (!Array.isArray(body.data)) {
return [];
}
// Filter out non-chat models. Some patterns are DashScope-specific (sambert, cosyvoice,
// wordart, wanx, wan2, paraformer) but harmless for other providers.
const NON_CHAT_RE =
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|z-image|s2s|livetranslate|realtime|gui-/i;
return body.data
.map((m) => m.id)
.filter((id) => !NON_CHAT_RE.test(id))
.sort();
} catch (e) {
setupDispatchLog(
"V8NQ4JT6",
`fetch models failed: ${e instanceof Error ? e.message : String(e)}`,
);
return [];
}
}
type PresetProvider = ReturnType<typeof loadPresetProviders>[number];
function printProviderMenu(presets: readonly PresetProvider[]): void {
const numWidth = String(presets.length + 1).length;
printCliLine("Select a provider:\n");
for (let i = 0; i < presets.length; i++) {
const p = presets.at(i);
if (!p) continue;
const num = String(i + 1).padStart(numWidth);
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(presets.length + 1).padStart(numWidth);
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
printCliLine("");
}
async function selectProvider(
rl: { question: (q: string) => Promise<string> },
presets: readonly PresetProvider[],
): Promise<Result<{ provider: string; baseUrl: string }, string>> {
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
return err(`invalid choice: ${choice}`);
}
if (choiceNum <= presets.length) {
const selected = presets.at(choiceNum - 1);
if (!selected) return err(`invalid choice: ${choice}`);
printCliLine(`\n → ${selected.label} (${selected.baseUrl})\n`);
return ok({ provider: selected.name, baseUrl: selected.baseUrl });
}
const provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
if (provider === "") return err("provider name must not be empty");
const baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
if (baseUrl === "") return err("base URL must not be empty");
return ok({ provider, baseUrl });
}
function printModelList(models: string[]): void {
const cols = process.stdout.columns || 80;
const nw = String(models.length).length;
const prefixLen = nw + 4;
const maxModelLen = Math.max(...models.map((m) => m.length));
const cellWidth = prefixLen + maxModelLen + 2;
const numCols = Math.max(1, Math.floor(cols / cellWidth));
for (let i = 0; i < models.length; i += numCols) {
const cells: string[] = [];
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
const num = String(j + 1).padStart(nw);
const model = models.at(j) ?? "";
cells.push(` ${num}) ${model.padEnd(maxModelLen + 2)}`);
}
printCliLine(cells.join(""));
}
}
async function selectModel(
rl: { question: (q: string) => Promise<string> },
models: string[],
): Promise<Result<string, string>> {
if (models.length > 0) {
printCliLine(`\nAvailable models (${models.length}):\n`);
printModelList(models);
printCliLine(`\nChoose a number, or type a model name directly.`);
const modelInput = await promptLine(rl, `Default model [1-${models.length}]: `);
if (modelInput === "") return err("default model must not be empty");
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
return ok(models.at(modelNum - 1) ?? modelInput);
}
return ok(modelInput);
}
printCliWarn("Could not fetch models (API may not support /models endpoint).");
const modelInput = await promptLine(rl, `Default model (e.g. qwen-plus, gpt-4o): `);
if (modelInput === "") return err("default model must not be empty");
return ok(modelInput);
}
async function selectWorkspace(rl: {
question: (q: string) => Promise<string>;
}): Promise<string | null> {
while (true) {
const wsPath = await promptLine(
rl,
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
);
if (wsPath.toLowerCase() === "skip") return null;
const candidate = wsPath === "" ? "./workflows" : wsPath;
const resolved = resolvePath(process.cwd(), candidate);
if (existsSync(resolved)) {
printCliWarn(`directory already exists: ${resolved}`);
printCliLine("Please enter a different path, or type 'skip' to skip.");
continue;
}
return candidate;
}
}
function stripProviderPrefix(model: string): string {
if (model.includes("/")) {
return model.split("/").pop() ?? model;
}
return model;
}
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
const rl = createInterface({ input, output });
try {
printCliLine("Configure the LLM provider that workflow agents will use.\n");
const presets = loadPresetProviders();
printProviderMenu(presets);
const providerResult = await selectProvider(rl, presets);
if (!providerResult.ok) {
rl.close();
return providerResult;
}
const { provider, baseUrl } = providerResult.value;
rl.close();
const apiKey = await promptSecret("API key for this provider: ");
if (apiKey === "") return err("API key must not be empty");
const rl2 = createInterface({ input, output });
printCliLine("\nFetching available models...");
const models = await fetchAvailableModels(baseUrl, apiKey);
const modelResult = await selectModel(rl2, models);
if (!modelResult.ok) {
rl2.close();
return modelResult;
}
const bare = stripProviderPrefix(modelResult.value);
const defaultModel = `${provider}/${bare}`;
printCliLine(`${defaultModel}`);
const initWorkspaceName = await selectWorkspace(rl2);
rl2.close();
return ok({ provider, baseUrl, apiKey, defaultModel, initWorkspaceName });
} catch (e) {
return err(e instanceof Error ? e.message : String(e));
}
}
export async function dispatchSetup(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseSetupArgv(argv);
if (!parsed.ok) {
printCliError(`${parsed.error}\n\n${usageSetup()}`);
return 1;
}
if (parsed.value === "help") {
printCliLine(usageSetup());
return 0;
}
let args: SetupCliArgs;
if (parsed.value === "interactive") {
const collected = await collectInteractiveSetup();
if (!collected.ok) {
printCliError(collected.error);
return 1;
}
args = collected.value;
} else {
args = parsed.value;
}
const result = await cmdSetup(storageRoot, args);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printSetupSummary(result.value);
return 0;
}
@@ -0,0 +1,4 @@
export { dispatchSetup } from "./dispatch.js";
export { loadPresetProviders } from "./preset-providers.js";
export { cmdSetup, printSetupSummary } from "./setup.js";
export type { CmdSetupSuccess, PresetProvider, SetupCliArgs } from "./types.js";
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import type { PresetProvider } from "./types.js";
type RawPresetEntry = {
name: unknown;
label: unknown;
baseUrl: unknown;
};
function isRawEntry(v: unknown): v is RawPresetEntry {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
}
let cached: ReadonlyArray<PresetProvider> | null = null;
export function loadPresetProviders(): ReadonlyArray<PresetProvider> {
if (cached !== null) return cached;
const yamlPath = join(import.meta.dirname, "providers.yaml");
const raw = readFileSync(yamlPath, "utf8");
const parsed: unknown = parseYaml(raw);
if (!Array.isArray(parsed)) {
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
}
const result: PresetProvider[] = [];
for (const entry of parsed) {
if (!isRawEntry(entry)) {
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
}
result.push({
name: entry.name as string,
label: entry.label as string,
baseUrl: entry.baseUrl as string,
});
}
cached = result;
return result;
}
@@ -0,0 +1,73 @@
# Preset LLM providers for `uncaged-workflow setup`.
# Each entry needs a provider name (used in workflow.yaml) and an OpenAI-compatible base URL.
# Add new providers here — no code changes required.
# ── International ──────────────────────────────────────────
- name: openai
label: OpenAI
baseUrl: https://api.openai.com/v1
- name: xai
label: xAI
baseUrl: https://api.x.ai/v1
- name: openrouter
label: OpenRouter
baseUrl: https://openrouter.ai/api/v1
- name: venice
label: Venice
baseUrl: https://api.venice.ai/api/v1
# ── China ──────────────────────────────────────────────────
- name: dashscope
label: DashScope (Alibaba)
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
- name: deepseek
label: DeepSeek
baseUrl: https://api.deepseek.com/v1
- name: siliconflow
label: SiliconFlow
baseUrl: https://api.siliconflow.cn/v1
- name: volcengine
label: Volcengine (ByteDance)
baseUrl: https://ark.cn-beijing.volces.com/api/v3
- name: kimi
label: Kimi (Moonshot)
baseUrl: https://api.moonshot.cn/v1
- name: glm
label: GLM (Zhipu AI)
baseUrl: https://open.bigmodel.cn/api/paas/v4
- name: glm-intl
label: GLM (Zhipu AI Intl)
baseUrl: https://api.z.ai/api/paas/v4
- name: stepfun
label: StepFun
baseUrl: https://api.stepfun.com/v1
- name: minimax
label: MiniMax
baseUrl: https://api.minimax.io/v1
- name: tencent
label: Tencent TokenHub
baseUrl: https://tokenhub.tencentmaas.com/v1
- name: xiaomi
label: Xiaomi MiMo
baseUrl: https://api.xiaomimimo.com/v1
# ── Local ──────────────────────────────────────────────────
- name: ollama
label: Ollama (local)
baseUrl: http://localhost:11434/v1
@@ -0,0 +1,103 @@
import { err, ok, type Result, type WorkflowConfig } from "@uncaged/workflow-protocol";
import {
readWorkflowRegistry,
splitProviderModelRef,
workflowRegistryPath,
writeWorkflowRegistry,
} from "@uncaged/workflow-register";
import { createLogger } from "@uncaged/workflow-util";
import { printCliLine } from "../../cli-output.js";
import { cmdInitWorkspace } from "../init/index.js";
import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
const setupLog = createLogger({ sink: { kind: "stderr" } });
function mergeWorkflowConfig(
prev: WorkflowConfig | null,
input: SetupCliArgs,
): Result<WorkflowConfig, string> {
const modelSplit = splitProviderModelRef(input.defaultModel);
if (!modelSplit.ok) {
return err(modelSplit.error);
}
if (modelSplit.value.providerName !== input.provider) {
return err(
`default model provider "${modelSplit.value.providerName}" must match --provider "${input.provider}"`,
);
}
const maxDepth = prev === null ? 3 : prev.maxDepth;
const supervisorInterval = prev === null ? 3 : prev.supervisorInterval;
const providers = {
...(prev === null ? {} : prev.providers),
[input.provider]: { baseUrl: input.baseUrl, apiKey: input.apiKey },
};
const models = { ...(prev === null ? {} : prev.models), default: input.defaultModel };
return ok({
maxDepth,
supervisorInterval,
providers,
models,
});
}
export async function cmdSetup(
storageRoot: string,
input: SetupCliArgs,
): Promise<Result<CmdSetupSuccess, string>> {
const readResult = await readWorkflowRegistry(storageRoot);
if (!readResult.ok) {
setupLog("W8JH4Q2K", `read workflow registry failed: ${readResult.error.message}`);
return err(readResult.error.message);
}
const current = readResult.value;
const merged = mergeWorkflowConfig(current.config, input);
if (!merged.ok) {
return merged;
}
const nextConfig = merged.value;
const nextRegistry = {
config: nextConfig,
workflows: current.workflows,
};
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
if (!written.ok) {
setupLog("M2NB5VX9", `write workflow registry failed: ${written.error.message}`);
return err(written.error.message);
}
const registryPath = workflowRegistryPath(storageRoot);
let initWorkspaceRootPath: string | null = null;
if (input.initWorkspaceName !== null) {
const initResult = await cmdInitWorkspace(process.cwd(), input.initWorkspaceName);
if (!initResult.ok) {
setupLog("T7QC4HWP", `init workspace failed: ${initResult.error}`);
return err(initResult.error);
}
initWorkspaceRootPath = initResult.value.rootPath;
}
return ok({
registryPath,
provider: input.provider,
defaultModel: input.defaultModel,
maxDepth: nextConfig.maxDepth,
supervisorInterval: nextConfig.supervisorInterval,
initWorkspaceRootPath,
});
}
export function printSetupSummary(result: CmdSetupSuccess): void {
printCliLine(`wrote registry: ${result.registryPath}`);
printCliLine(`provider "${result.provider}" (baseUrl + apiKey updated)`);
printCliLine(`config.models.default = "${result.defaultModel}"`);
printCliLine(`maxDepth=${result.maxDepth}, supervisorInterval=${result.supervisorInterval}`);
if (result.initWorkspaceRootPath !== null) {
printCliLine(`initialized workflow workspace at ${result.initWorkspaceRootPath}`);
}
}
@@ -0,0 +1,23 @@
/** Parsed non-interactive `setup` CLI arguments (all fields required for agent mode). */
export type SetupCliArgs = {
provider: string;
baseUrl: string;
apiKey: string;
defaultModel: string;
initWorkspaceName: string | null;
};
export type PresetProvider = {
name: string;
label: string;
baseUrl: string;
};
export type CmdSetupSuccess = {
registryPath: string;
provider: string;
defaultModel: string;
maxDepth: number;
supervisorInterval: number;
initWorkspaceRootPath: string | null;
};
@@ -1,4 +1,4 @@
import type { Result } from "@uncaged/workflow";
import type { Result } from "@uncaged/workflow-protocol";
import {
readWorkerCtl,
@@ -26,12 +26,7 @@ export async function dispatchRun(storageRoot: string, argv: string[]): Promise<
return 1;
}
const result = await cmdRun(
storageRoot,
parsed.value.name,
parsed.value.prompt,
parsed.value.maxRounds,
);
const result = await cmdRun(storageRoot, parsed.value.name, parsed.value.prompt);
if (!result.ok) {
printCliError(result.error);
return 1;
@@ -166,7 +161,7 @@ export async function dispatchFork(storageRoot: string, argv: string[]): Promise
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
run: {
handler: dispatchRun,
args: "<name> [--prompt <text>] [--max-rounds N]",
args: "<name> [--prompt <text>]",
description: "Start a new thread executing a workflow",
},
list: {
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type { ParsedForkArgv } from "./types.js";
@@ -0,0 +1,69 @@
import { join } from "node:path";
import { createCasStore } from "@uncaged/workflow-cas";
import { prepareCasFork } from "@uncaged/workflow-execute";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { generateUlid, getGlobalCasDir } from "@uncaged/workflow-util";
import { pathExists } from "../../fs-utils.js";
import { resolveThreadRecord } from "../../thread-scan.js";
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
export async function cmdFork(
storageRoot: string,
threadId: string,
fromRole: string | null,
): Promise<Result<{ threadId: string }, string>> {
const resolved = await resolveThreadRecord(storageRoot, threadId);
if (resolved === null) {
return err(`thread not found: ${threadId}`);
}
const bundlePath = join(storageRoot, "bundles", `${resolved.bundleHash}.esm.js`);
if (!(await pathExists(bundlePath))) {
return err(`bundle file missing for thread hash ${resolved.bundleHash}`);
}
const cas = createCasStore(getGlobalCasDir(storageRoot));
const newThreadId = generateUlid(Date.now());
const plan = await prepareCasFork({
cas,
bundleDir: resolved.bundleDir,
bundleHash: resolved.bundleHash,
sourceThreadId: threadId,
headHash: resolved.head,
startHash: resolved.start,
newThreadId,
fromRole,
});
if (!plan.ok) {
return plan;
}
const worker = await ensureWorkerForHash(storageRoot, plan.value.hash, bundlePath);
if (!worker.ok) {
return worker;
}
const p = plan.value;
const sent = await sendWorkerTcpCommand(
worker.value.port,
{
type: "run",
threadId: newThreadId,
workflowName: p.workflowName,
prompt: p.prompt,
options: p.runOptions,
steps: p.steps,
stepTimestamps: p.stepTimestamps.length > 0 ? p.stepTimestamps : null,
forkSourceThreadId: threadId,
forkContinuation: p.forkContinuation,
},
{ awaitResponseLine: false },
);
if (!sent.ok) {
return sent;
}
return ok({ threadId: newThreadId });
}
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { listHistoricalThreads } from "../../thread-scan.js";
import { validateCliWorkflowName } from "../../workflow-name.js";
@@ -1,22 +1,26 @@
import { watch } from "node:fs";
import { readFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import {
type CasStore,
createCasStore,
getContentMerklePayload,
getGlobalCasDir,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
} from "@uncaged/workflow";
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
FORK_BRANCH_ROLE,
readThreadsIndex,
type ThreadIndex,
walkStateFramesNewestFirst,
} from "@uncaged/workflow-execute";
import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol";
import { END } from "@uncaged/workflow-runtime";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { pathExists } from "../../fs-utils.js";
import type { ParsedLiveArgv } from "../../live-argv.js";
import { findLatestThreadDataPath, resolveThreadDataPath } from "../../thread-scan.js";
import {
findLatestThreadBundleTarget,
type LatestThreadTarget,
resolveThreadRecord,
} from "../../thread-scan.js";
import type { LiveRoleRow } from "./types.js";
export const LIVE_CONTENT_MAX_LINES = 10;
@@ -54,16 +58,15 @@ function printSummary(result: WorkflowCompletion): void {
printCliLine(`completed: returnCode=${result.returnCode}${result.summary}`);
}
type LiveSessionState = {
sawStart: boolean;
completed: boolean;
type InfoLiveState = {
carry: string;
contentOffset: number;
};
type InfoLiveState = {
carry: string;
contentOffset: number;
type CasLiveState = {
printedHashes: Set<string>;
lastHead: string | null;
completionEmitted: boolean;
};
function tryParseInfoRecord(obj: Record<string, unknown>): {
@@ -85,102 +88,140 @@ function tryParseInfoRecord(obj: Record<string, unknown>): {
return { tag, content, timestamp };
}
async function handleJsonlLine(
rawLine: string,
state: LiveSessionState,
roleFilter: string | null,
cas: CasStore,
): Promise<{ parseError: string | null; workflowResult: WorkflowCompletion | null }> {
const trimmed = rawLine.trim();
if (trimmed === "") {
return { parseError: null, workflowResult: null };
function completionFromEndMeta(meta: Record<string, unknown>): WorkflowCompletion | null {
const returnCode = meta.returnCode;
const summary = meta.summary;
if (typeof returnCode !== "number" || typeof summary !== "string") {
return null;
}
return { returnCode, summary };
}
let rec: unknown;
try {
rec = JSON.parse(trimmed) as unknown;
} catch {
return { parseError: "invalid JSON in thread data file", workflowResult: null };
async function emitRoleStepPrint(params: {
cas: CasStore;
role: string;
contentHash: string;
meta: Record<string, unknown>;
timestamp: number;
roleFilter: string | null;
}): Promise<void> {
if (params.roleFilter !== null && params.role !== params.roleFilter) {
return;
}
if (rec === null || typeof rec !== "object") {
return { parseError: "invalid record in thread data file", workflowResult: null };
}
const obj = rec as Record<string, unknown>;
if (!state.sawStart) {
state.sawStart = true;
return { parseError: null, workflowResult: null };
}
const wf = tryParseWorkflowResultRecord(obj);
if (wf !== null) {
state.completed = true;
return { parseError: null, workflowResult: wf };
}
const roleRow = tryParseRoleStepRecord(obj);
if (roleRow === null) {
return {
parseError: "unrecognized record in thread data (expected role step or result)",
workflowResult: null,
};
}
if (roleFilter !== null && roleRow.role !== roleFilter) {
return { parseError: null, workflowResult: null };
}
const payload = await getContentMerklePayload(cas, roleRow.contentHash);
const payload = await getContentMerklePayload(params.cas, params.contentHash);
const content =
payload !== null ? payload : `(content not in CAS; contentHash=${roleRow.contentHash})`;
payload !== null ? payload : `(content not in CAS; contentHash=${params.contentHash})`;
const row: LiveRoleRow = {
role: roleRow.role,
role: params.role,
content,
meta: roleRow.meta,
timestamp: roleRow.timestamp,
meta: params.meta,
timestamp: params.timestamp,
};
for (const outLine of renderLiveRoleStepLines(row, highlightLiveRole(row.role))) {
printCliLine(outLine);
}
return { parseError: null, workflowResult: null };
}
async function pumpNewContent(
dataPath: string,
state: LiveSessionState,
roleFilter: string | null,
cas: CasStore,
): Promise<number | null> {
let text: string;
async function emitStatesReachableFromHead(params: {
cas: CasStore;
headHash: string;
state: CasLiveState;
roleFilter: string | null;
}): Promise<WorkflowCompletion | null> {
const frames = await walkStateFramesNewestFirst(params.cas, params.headHash);
const chronological = [...frames].reverse();
for (const fr of chronological) {
if (params.state.printedHashes.has(fr.hash)) {
continue;
}
params.state.printedHashes.add(fr.hash);
const role = fr.payload.role;
if (role === FORK_BRANCH_ROLE) {
continue;
}
if (role === END) {
const wf = completionFromEndMeta(fr.payload.meta);
if (wf !== null) {
printSummary(wf);
return wf;
}
continue;
}
await emitRoleStepPrint({
cas: params.cas,
role,
contentHash: fr.payload.content,
meta: fr.payload.meta,
timestamp: fr.payload.timestamp,
roleFilter: params.roleFilter,
});
}
return null;
}
async function pumpThreadsJson(params: {
storageRoot: string;
bundleDir: string;
bundleHash: string;
threadId: string;
state: CasLiveState;
roleFilter: string | null;
cas: CasStore;
}): Promise<number | null> {
let idx: ThreadIndex;
try {
text = await readFile(dataPath, "utf8");
idx = await readThreadsIndex(params.bundleDir);
} catch {
return null;
idx = {};
}
if (text.length < state.contentOffset) {
state.contentOffset = 0;
state.carry = "";
const active = idx[params.threadId];
if (active === undefined) {
if (params.state.completionEmitted) {
return null;
}
const hist = await resolveThreadRecord(params.storageRoot, params.threadId);
if (hist === null || hist.source !== "history") {
return null;
}
params.state.completionEmitted = true;
const wf = await emitStatesReachableFromHead({
cas: params.cas,
headHash: hist.head,
state: params.state,
roleFilter: params.roleFilter,
});
return wf !== null ? 0 : null;
}
const chunk = text.slice(state.contentOffset);
state.contentOffset = text.length;
state.carry += chunk;
const head = active.head;
if (params.state.lastHead === null) {
params.state.lastHead = head;
const wf = await emitStatesReachableFromHead({
cas: params.cas,
headHash: head,
state: params.state,
roleFilter: params.roleFilter,
});
return wf !== null ? 0 : null;
}
const parts = state.carry.split("\n");
state.carry = parts.pop() ?? "";
for (const line of parts) {
const { parseError, workflowResult } = await handleJsonlLine(line, state, roleFilter, cas);
if (parseError !== null) {
printCliError(parseError);
return 1;
}
if (workflowResult !== null) {
printSummary(workflowResult);
return 0;
}
if (head !== params.state.lastHead) {
params.state.lastHead = head;
const wf = await emitStatesReachableFromHead({
cas: params.cas,
headHash: head,
state: params.state,
roleFilter: params.roleFilter,
});
return wf !== null ? 0 : null;
}
return null;
@@ -297,9 +338,9 @@ function watchLivePaths(params: { tasks: WatchPumpTask[]; signal: AbortSignal })
schedulePump(path, pump);
});
watchers.push(watcher);
watcher.on("error", (err: Error) => {
watcher.on("error", (errObj: Error) => {
closeAll();
reject(err);
reject(errObj);
});
}
@@ -315,17 +356,14 @@ function watchLivePaths(params: { tasks: WatchPumpTask[]; signal: AbortSignal })
});
}
type LiveThreadTarget = {
threadId: string;
dataPath: string;
};
type LiveThreadTarget = LatestThreadTarget;
async function resolveLiveThreadTarget(
storageRoot: string,
parsed: ParsedLiveArgv,
): Promise<LiveThreadTarget | null> {
if (parsed.latest) {
const found = await findLatestThreadDataPath(storageRoot);
const found = await findLatestThreadBundleTarget(storageRoot);
if (found === null) {
printCliError("live: no threads found");
return null;
@@ -338,36 +376,56 @@ async function resolveLiveThreadTarget(
printCliError("live: internal error: missing thread id");
return null;
}
const resolved = await resolveThreadDataPath(storageRoot, id);
const resolved = await resolveThreadRecord(storageRoot, id);
if (resolved === null) {
printCliError(`thread not found: ${id}`);
return null;
}
return { threadId: id, dataPath: resolved };
return {
threadId: id,
bundleHash: resolved.bundleHash,
bundleDir: resolved.bundleDir,
threadsJsonPath: join(resolved.bundleDir, "threads.json"),
};
}
async function buildLiveWatchTasks(params: {
dataPath: string;
infoPath: string;
storageRoot: string;
target: LiveThreadTarget;
debug: boolean;
dataState: LiveSessionState;
dataState: CasLiveState;
infoState: InfoLiveState;
roleFilter: string | null;
cas: CasStore;
}): Promise<WatchPumpTask[]> {
const { dataPath, infoPath, debug, dataState, infoState, roleFilter, cas } = params;
const infoPath = join(
params.storageRoot,
"logs",
params.target.bundleHash,
`${params.target.threadId}.info.jsonl`,
);
const tasks: WatchPumpTask[] = [
{
path: dataPath,
pump: () => pumpNewContent(dataPath, dataState, roleFilter, cas),
path: params.target.threadsJsonPath,
pump: () =>
pumpThreadsJson({
storageRoot: params.storageRoot,
bundleDir: params.target.bundleDir,
bundleHash: params.target.bundleHash,
threadId: params.target.threadId,
state: params.dataState,
roleFilter: params.roleFilter,
cas: params.cas,
}),
},
];
if (debug && (await pathExists(infoPath))) {
if (params.debug && (await pathExists(infoPath))) {
tasks.push({
path: infoPath,
pump: async () => {
await pumpNewInfoContent(infoPath, infoState);
await pumpNewInfoContent(infoPath, params.infoState);
return null;
},
});
@@ -382,16 +440,13 @@ export async function cmdLive(storageRoot: string, parsed: ParsedLiveArgv): Prom
return 1;
}
const { threadId, dataPath } = target;
const roleFilter = parsed.role;
const infoPath = join(dirname(dataPath), `${threadId}.info.jsonl`);
const cas = createCasStore(getGlobalCasDir(storageRoot));
const dataState: LiveSessionState = {
sawStart: false,
completed: false,
carry: "",
contentOffset: 0,
const dataState: CasLiveState = {
printedHashes: new Set<string>(),
lastHead: null,
completionEmitted: false,
};
const infoState: InfoLiveState = {
@@ -406,22 +461,29 @@ export async function cmdLive(storageRoot: string, parsed: ParsedLiveArgv): Prom
process.on("SIGINT", onSigInt);
try {
const firstData = await pumpNewContent(dataPath, dataState, roleFilter, cas);
if (firstData === 1) {
return 1;
}
await mkdir(dirname(target.threadsJsonPath), { recursive: true });
const firstData = await pumpThreadsJson({
storageRoot,
bundleDir: target.bundleDir,
bundleHash: target.bundleHash,
threadId: target.threadId,
state: dataState,
roleFilter,
cas,
});
const infoPath = join(storageRoot, "logs", target.bundleHash, `${target.threadId}.info.jsonl`);
if (parsed.debug && (await pathExists(infoPath))) {
await pumpNewInfoContent(infoPath, infoState);
}
if (firstData === 0 || dataState.completed) {
if (firstData === 0) {
return 0;
}
const tasks = await buildLiveWatchTasks({
dataPath,
infoPath,
storageRoot,
target,
debug: parsed.debug,
dataState,
infoState,
@@ -0,0 +1,39 @@
import { unlink } from "node:fs/promises";
import { join } from "node:path";
import {
garbageCollectCas,
removeThreadEntry,
removeThreadHistoryEntries,
} from "@uncaged/workflow-execute";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { resolveThreadRecord } from "../../thread-scan.js";
export async function cmdThreadRemove(
storageRoot: string,
threadId: string,
): Promise<Result<void, string>> {
const resolved = await resolveThreadRecord(storageRoot, threadId);
if (resolved === null) {
return err(`thread not found: ${threadId}`);
}
// 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`);
const runningPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.running`);
await unlink(infoPath).catch(() => {});
await unlink(runningPath).catch(() => {});
await garbageCollectCas(storageRoot);
return ok(undefined);
}
@@ -1,13 +1,8 @@
import { join } from "node:path";
import {
err,
generateUlid,
getRegisteredWorkflow,
ok,
type Result,
readWorkflowRegistry,
} from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
import { generateUlid } from "@uncaged/workflow-util";
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
import { validateCliWorkflowName } from "../../workflow-name.js";
@@ -15,7 +10,6 @@ export async function cmdRun(
storageRoot: string,
name: string,
prompt: string,
maxRounds: number,
): Promise<Result<{ threadId: string }, string>> {
const nameOk = validateCliWorkflowName(name);
if (!nameOk.ok) {
@@ -46,7 +40,7 @@ export async function cmdRun(
threadId,
workflowName: name,
prompt,
options: { maxRounds, depth: 0 },
options: { depth: 0 },
},
{ awaitResponseLine: false },
);
@@ -0,0 +1,74 @@
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { END } from "@uncaged/workflow-runtime";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { resolveThreadRecord } from "../../thread-scan.js";
async function readParentStateFromStartNode(
cas: { get(hash: string): Promise<string | null> },
startHash: string,
): Promise<string | null> {
const yamlText = await cas.get(startHash);
if (yamlText === null) {
return null;
}
const parsed = parseCasThreadNode(yamlText);
if (parsed === null || parsed.kind !== "start") {
return null;
}
return parsed.node.payload.parentState;
}
export async function cmdThreadShow(
storageRoot: string,
threadId: string,
): Promise<Result<string, string>> {
const resolved = await resolveThreadRecord(storageRoot, threadId);
if (resolved === null) {
return err(`thread not found: ${threadId}`);
}
const cas = createCasStore(getGlobalCasDir(storageRoot));
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
const chronological = [...frames].reverse();
const parentState = await readParentStateFromStartNode(cas, resolved.start);
const steps: Array<{
role: string;
hash: string;
timestamp: number;
content: string;
childThread: string | null;
}> = [];
for (const fr of chronological) {
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
continue;
}
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
steps.push({
role: fr.payload.role,
hash: fr.hash,
timestamp: fr.payload.timestamp,
content:
payloadText !== null
? payloadText
: `(content not in CAS; contentHash=${fr.payload.content})`,
childThread: fr.payload.childThread,
});
}
const payload = {
threadId: resolved.threadId,
bundleHash: resolved.bundleHash,
head: resolved.head,
start: resolved.start,
parentState,
source: resolved.source,
steps,
};
return ok(JSON.stringify(payload, null, 2));
}
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type { ParsedAddArgv } from "./types.js";

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