Compare commits
432 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43978360ff | |||
| ba90214af6 | |||
| 5bbac3e4f7 | |||
| 131021b1a7 | |||
| e42555fd9c | |||
| 3a26eb28e5 | |||
| c1a17b707c | |||
| 4ea1e0d8a4 | |||
| b1a9d2ec3f | |||
| 2b8707a706 | |||
| 241bfbf6d9 | |||
| 40530d757e | |||
| 0f3661b566 | |||
| 9c44c709e9 | |||
| 8892ab9978 | |||
| 7ec86d82a3 | |||
| f728b36e8d | |||
| 3431d3070b | |||
| 576df067d4 | |||
| a46a225d04 | |||
| f74b482cc0 | |||
| 89abfdc257 | |||
| 77e395b913 | |||
| b65a006d45 | |||
| 5994548f0b | |||
| 0871ae54ea | |||
| 9576d69032 | |||
| 64dadf114d | |||
| baaa1d1dc8 | |||
| 3074cd5f0c | |||
| 15edc99c72 | |||
| 153178c545 | |||
| fac215bd21 | |||
| 9822e68c55 | |||
| 764b73209e | |||
| e7987c4cd7 | |||
| 942ff4b1a4 | |||
| f5977c46c6 | |||
| 71ccf8d03c | |||
| 510b49287a | |||
| bb6b309efa | |||
| 56db22a908 | |||
| 2a1b7b0aeb | |||
| d037eca4ae | |||
| b9d543a465 | |||
| 07f52594d1 | |||
| c7b426ff5a | |||
| 4582274ba4 | |||
| d140801337 | |||
| 4563f1bb5e | |||
| 59b7e89028 | |||
| 019d8c1ee9 | |||
| 5e783e7a24 | |||
| a450a88b16 | |||
| 5b47317cef | |||
| 3384c38d02 | |||
| b370d96504 | |||
| 8cae114c7e | |||
| c2c6fc5304 | |||
| 94f725c50b | |||
| 9b23e6f85a | |||
| 238a94f7a6 | |||
| 236c771e4e | |||
| 0ffd84cf7d | |||
| e14643a50b | |||
| 76830c5e22 | |||
| 90a388f5ab | |||
| 82e40f0c21 | |||
| 8d650326db | |||
| dd3eec7d35 | |||
| 9276689cb6 | |||
| b4584cbaa6 | |||
| 1cf963a1fb | |||
| ce5bc50210 | |||
| 439e203113 | |||
| 522afdd4bd | |||
| ca644dabaa | |||
| 9d9c00df98 | |||
| a1c5dc3e92 | |||
| c85980f604 | |||
| eff5fb332a | |||
| 658a4a24ef | |||
| aabfd90a87 | |||
| 0207f93303 | |||
| e1423f196b | |||
| ae6954a02f | |||
| aede8f7613 | |||
| 6d1e0498ba | |||
| 6cce5e2593 | |||
| d3a7ed9062 | |||
| e7f733c393 | |||
| d4bb4a9324 | |||
| e4900b6fd6 | |||
| 39540d9ae8 | |||
| 10899364d4 | |||
| dc5fdd7358 | |||
| bb1293f6b9 | |||
| 55b3b61498 | |||
| 484ed520cd | |||
| 497f03c747 | |||
| cfe4543d39 | |||
| 399b967c59 | |||
| 061926b86a | |||
| acb0ebed97 | |||
| d5d7be6100 | |||
| 1566a43395 | |||
| afbde4573a | |||
| 63e447fc3d | |||
| 34fcbf29cb | |||
| 256799fcfd | |||
| 21cf3db111 | |||
| ed38543db4 | |||
| 78771fbebc | |||
| c15f58bdeb | |||
| 6d4bf108bb | |||
| 5b7c9b844b | |||
| f0d1bb9ae8 | |||
| 04cfd33f99 | |||
| a8c00f169b | |||
| c4d34530e8 | |||
| 90a410c00a | |||
| 6276ca5a4a | |||
| 8e63f99eb6 | |||
| 9ca70bbb69 | |||
| ed1f38c7da | |||
| 1664d68b50 | |||
| 1871ef31b4 | |||
| ec3c97b200 | |||
| 18e3dc7603 | |||
| fc229cac79 | |||
| ec555b43d1 | |||
| c8de86d7c9 | |||
| bd110b76e1 | |||
| dc10ccceaa | |||
| c040a90a8f | |||
| ec4599a230 | |||
| 1f4bd3f431 | |||
| bebf4aad45 | |||
| 11ba185fef | |||
| 730340d123 | |||
| c848216396 | |||
| 2698e0a6cb | |||
| 47f2b1a128 | |||
| 0c02cb7574 | |||
| 320810ec25 | |||
| 91f585c534 | |||
| 299ff126d9 | |||
| 931eb81458 | |||
| c604d1f600 | |||
| 20bcc65f61 | |||
| f5612ef1b5 | |||
| a92deeaf3f | |||
| 1e936cf04a | |||
| ea16057803 | |||
| 4493fd8979 | |||
| cc1ee8d5e3 | |||
| 0ad5c85f5a | |||
| d02d410dcd | |||
| cdf3c95622 | |||
| a7fea10383 | |||
| 3846dc12a9 | |||
| c5fd84432f | |||
| 4c4dabb7a3 | |||
| 1b62cec0a2 | |||
| ecc348f182 | |||
| 41209f1ef8 | |||
| 58a4aefcc4 | |||
| bbb79f821e | |||
| 05fbd4f5b5 | |||
| 7e7331eb2d | |||
| 0fbbf37548 | |||
| 2af39463de | |||
| 5f2458238f | |||
| aadec0b96c | |||
| 1c68ce6217 | |||
| 7265603b55 | |||
| 74cea09ac0 | |||
| b1e66fa7a4 | |||
| 81a7a8c7c1 | |||
| 9cb7d68abe | |||
| 98122b446d | |||
| 4a31cf9d63 | |||
| 2c26be6ec6 | |||
| f723daa014 | |||
| 1e9900bed3 | |||
| aebff8b906 | |||
| db45089922 | |||
| 9c1b018ffa | |||
| a98431a12a | |||
| 0fe17b0fb2 | |||
| e37dbc3f35 | |||
| 82d9abf260 | |||
| 50aec2d0cf | |||
| e979a55f8a | |||
| 30f1582046 | |||
| cf0540d7fa | |||
| c05fac746c | |||
| 34efd25e91 | |||
| cc0bc6c8aa | |||
| 626cb5d98e | |||
| f87cb38a67 | |||
| 0970139418 | |||
| 376dd87b6b | |||
| 4d8469a649 | |||
| a929fa4ccb | |||
| ff3e19fd22 | |||
| b509d1715e | |||
| b93f6e736f | |||
| ec13c19505 | |||
| 203b86e827 | |||
| 90de1c7025 | |||
| 2b587612d5 | |||
| 2342a6e3bd | |||
| 0021596ff0 | |||
| 56ec8cd401 | |||
| fe87efd79d | |||
| b783027406 | |||
| 904ee1eb83 | |||
| 1742ced6df | |||
| 93145cf08c | |||
| da6bcb10d6 | |||
| 6fc97fc8c8 | |||
| 93d9821f64 | |||
| 29367cbe31 | |||
| ec397aecd3 | |||
| 2e9d939f8e | |||
| 064a24f093 | |||
| fede623a82 | |||
| 2a52b930b9 | |||
| bf2f790e6e | |||
| 08a79b77db | |||
| 22a6200b69 | |||
| 7e7f6aa6d6 | |||
| d6fe3f844c | |||
| d0803019b5 | |||
| f16e7641fd | |||
| 3b41625001 | |||
| c602d2284b | |||
| d96e10b0fc | |||
| 8e36d3e1f5 | |||
| bbe4fe0ed1 | |||
| e105c5cac1 | |||
| 578776fccf | |||
| cb756a999a | |||
| e0577ceefe | |||
| 024dd8c1e8 | |||
| 9e98119145 | |||
| fd8943f131 | |||
| f7253d5948 | |||
| 1c5636c270 | |||
| ca0403c8ab | |||
| aa25f55f63 | |||
| e29d1bf345 | |||
| f3aedf8d6c | |||
| 26cf51366f | |||
| 81c582ae0e | |||
| 6f000512d2 | |||
| 8f78a00063 | |||
| 6c2a137aef | |||
| 6cd856ca99 | |||
| 064696c558 | |||
| 0f28e9b61a | |||
| 1ea56009a2 | |||
| 6cc2481a16 | |||
| 44018bd17d | |||
| 28c35bb3e0 | |||
| b8b557baf6 | |||
| 727b4bb3ed | |||
| 9bbdfc41bd | |||
| b07f8cf166 | |||
| 1a1e8b3398 | |||
| 39d2a61686 | |||
| bf0bc47a3f | |||
| 2cffaad127 | |||
| 9a3daac657 | |||
| b8f9ffcb59 | |||
| a7171f05f6 | |||
| b53667a2aa | |||
| 5b60fa6454 | |||
| 2c0e744ebf | |||
| ae16f09688 | |||
| 73a3638ad9 | |||
| 7b0260cedd | |||
| 61fc1cfe1b | |||
| 6b1e728700 | |||
| dedab62c49 | |||
| a44f1f34a8 | |||
| 8ff6f7e778 | |||
| e04e75bdee | |||
| c65c29c1b5 | |||
| cc3f2b576c | |||
| 884ff85205 | |||
| a11cc62a81 | |||
| 34f5e655d1 | |||
| 44fb0694aa | |||
| cdcaff15ab | |||
| 402479ddef | |||
| a28dd3050e | |||
| ce0d0a962c | |||
| 46b552ec01 | |||
| 587518ac09 | |||
| e9e4960714 | |||
| 495c000356 | |||
| 7e662f9287 | |||
| 3ed38c65ec | |||
| 38f2b0eeb2 | |||
| 586a0f824e | |||
| 178f6c7519 | |||
| 3153ab26f6 | |||
| 014c442ed2 | |||
| 1f7851d5e3 | |||
| e68790dfc7 | |||
| 520b17b351 | |||
| 085cdcd3f4 | |||
| a8c1c158d6 | |||
| 83649fd836 | |||
| a5c09adae6 | |||
| 9e6cd9d615 | |||
| 1f1128ff4a | |||
| aa01283ce1 | |||
| f81e2a8aac | |||
| 2b38e583be | |||
| 4ff1394224 | |||
| 2bbe5a3d0e | |||
| a4237c0462 | |||
| 321e5b1379 | |||
| 7c3e14c473 | |||
| aecce595e8 | |||
| cf17dedac3 | |||
| 661fdbb263 | |||
| 201abf98ce | |||
| 665965fd01 | |||
| 6a99f84025 | |||
| f61474bec0 | |||
| 9bdb18afd0 | |||
| 2af299f3ce | |||
| d9f79c60a1 | |||
| 485bfcb0b6 | |||
| a47ed06ea5 | |||
| 2ef004eecf | |||
| 2616259a0f | |||
| 23b2c3b47d | |||
| 7d3954097d | |||
| 4a925b98af | |||
| bfea771a52 | |||
| 5e411a1f19 | |||
| 21238f7825 | |||
| 6b3aa4ce35 | |||
| f042c9d640 | |||
| 66bca9ef03 | |||
| 309af39447 | |||
| 86a422f7e2 | |||
| 648f0c6dec | |||
| 8456a8337b | |||
| 9c8b98a551 | |||
| c3272be760 | |||
| c44b773a86 | |||
| 2776f8e419 | |||
| 7b0e256c13 | |||
| c663ba9e9c | |||
| 71b413f20c | |||
| 61be1c662a | |||
| 84e8d70da4 | |||
| 8976f4cf3b | |||
| 07730dd24c | |||
| 4eff4d2370 | |||
| 1d6da18b18 | |||
| c342ff3737 | |||
| 8fe26417cf | |||
| 990200230b | |||
| 4eaefd9974 | |||
| 1a685583bd | |||
| 19769efea6 | |||
| 7f64541c5b | |||
| 43a6600378 | |||
| 74e3f5434c | |||
| 220c9c5224 | |||
| cae59b589e | |||
| 703ac9dfcc | |||
| 2df8accf2f | |||
| b5cc0db17e | |||
| 6196e0974a | |||
| 410e9e6d9b | |||
| 84de74721d | |||
| 4403532f35 | |||
| e95e76c145 | |||
| af69e773a0 | |||
| 6488b7bbb4 | |||
| 15d39c96a7 | |||
| 30e4e99908 | |||
| a3c70a5041 | |||
| 12d58a8206 | |||
| c096f4d94e | |||
| 500401d93c | |||
| 43f466eb67 | |||
| fe829d9ae6 | |||
| f80535d742 | |||
| 0eab3b7001 | |||
| 37c5b89c98 | |||
| 0fdf19879a | |||
| f73bf1e313 | |||
| 8c4441bf6b | |||
| 341ff656dc | |||
| 4b44665c7e | |||
| 172e9b34cc | |||
| 96fc3e220a | |||
| 7926751b01 | |||
| 43e1f82303 | |||
| d472de1247 | |||
| 99a137422c | |||
| d351343aa8 | |||
| 2482fb7e62 | |||
| fa9163e462 | |||
| fce2bf7441 | |||
| c9cdfe37db | |||
| 45bb5af99a | |||
| c7b0beb6be | |||
| 79cf97e617 | |||
| 196562c82a | |||
| 267ca73a1b | |||
| aee71fd2e7 | |||
| 8ce1dd3cca | |||
| 4b27943871 | |||
| 8d5b97c67e | |||
| 513c006ce3 | |||
| 2cd2a7d713 | |||
| 94fa964b84 | |||
| 2c642b1a53 | |||
| c15a5554c0 | |||
| e38852a761 | |||
| fd8f1f2491 | |||
| 98b6153070 |
@@ -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).
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util": patch
|
||||
---
|
||||
|
||||
Replace optionalEnv/requireEnv with unified env(name, fallback) API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: correct internal dependency versions for prerelease
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util-agent": patch
|
||||
---
|
||||
|
||||
fix: include create-agent-adapter.ts in published src
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": minor
|
||||
---
|
||||
|
||||
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||
@@ -0,0 +1,40 @@
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Engine — Environment Variables
|
||||
# ──────────────────────────────────────────────
|
||||
# Copy this file to .env and fill in the values.
|
||||
|
||||
# ── Cursor Agent ──
|
||||
|
||||
# CLI command to invoke the Cursor agent (required for develop workflow)
|
||||
WORKFLOW_CURSOR_COMMAND=
|
||||
|
||||
# Model override for Cursor agent
|
||||
WORKFLOW_CURSOR_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Cursor agent operations
|
||||
WORKFLOW_CURSOR_TIMEOUT=
|
||||
|
||||
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
|
||||
|
||||
# CLI command to invoke the Hermes agent (absolute path required)
|
||||
WORKFLOW_HERMES_COMMAND=
|
||||
|
||||
# Model override for Hermes agent
|
||||
WORKFLOW_HERMES_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Hermes agent operations
|
||||
WORKFLOW_HERMES_TIMEOUT=
|
||||
|
||||
# ── Storage ──
|
||||
|
||||
# Override the workflow storage root directory
|
||||
# Default: ~/.uncaged/workflow
|
||||
WORKFLOW_STORAGE_ROOT=
|
||||
|
||||
# Gateway secret for the serve command
|
||||
WORKFLOW_DASHBOARD_SECRET=
|
||||
|
||||
# ── Display ──
|
||||
|
||||
# Set to any value to disable colored output
|
||||
# NO_COLOR=1
|
||||
Executable
+10
@@ -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!"
|
||||
@@ -2,3 +2,10 @@ node_modules/
|
||||
dist/
|
||||
bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
bunfig.toml
|
||||
xiaoju/
|
||||
solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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 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. Shared types live in `@uncaged/workflow-protocol`; bundle authors typically depend on `@uncaged/workflow-runtime`.
|
||||
|
||||
### Key Terms
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|---------|-----------|
|
||||
| **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`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. State lives in CAS (linked nodes); active threads indexed in `threads.json`; completed rows in `history/*.jsonl`. Debug logs use `.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. |
|
||||
|
||||
@@ -19,15 +19,29 @@
|
||||
```
|
||||
workflow/
|
||||
packages/
|
||||
workflow/ # @uncaged/workflow — core lib (types, hash, ULID, JSONL, registry)
|
||||
cli-workflow/ # @uncaged/cli-workflow — CLI (uncaged-workflow command)
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types + Result
|
||||
workflow-runtime/ # @uncaged/workflow-runtime — createWorkflow, type re-exports
|
||||
workflow-util/ # @uncaged/workflow-util — Base32, ULID, logger, storage paths, refs helpers
|
||||
workflow-reactor/ # @uncaged/workflow-reactor — LLM fn + thread reactor (tool calls)
|
||||
workflow-cas/ # @uncaged/workflow-cas — CAS store, hash, Merkle
|
||||
workflow-register/ # @uncaged/workflow-register — bundle validation, registry YAML, model resolution
|
||||
workflow-execute/ # @uncaged/workflow-execute — engine, extract, fork, GC, workflowAsAgent
|
||||
cli-workflow/ # @uncaged/cli-workflow — uncaged-workflow CLI
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||
workflow-dashboard/ # @uncaged/workflow-dashboard — React dashboard (private app)
|
||||
docs/ # RFCs, conventions
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
```
|
||||
|
||||
- `workflow` is the core; `cli-workflow` depends on it
|
||||
- Packages use `workspace:*` protocol
|
||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
@@ -97,6 +111,36 @@ type WorkflowEntry = {
|
||||
|
||||
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:
|
||||
|
||||
| # | Rule | Rationale |
|
||||
|---|------|-----------|
|
||||
| 1 | **Every folder exports via `index.ts`** | Single entry point for the module |
|
||||
| 2 | **Types live in `types.ts`** | Each folder's type definitions go in `<folder>/types.ts`, not scattered across files |
|
||||
| 3 | **Single export source** | Only `index.ts` may re-export. No file may re-export from another module's internals. Cross-module imports must go through `index.ts` — never reach past it to import a specific file |
|
||||
| 4 | **`index.ts` is pure re-exports** | No type definitions, no function implementations — only `export { ... } from` statements |
|
||||
|
||||
```typescript
|
||||
// ✅ Good — import through module boundary
|
||||
import { createCasStore } from "../cas/index.js";
|
||||
import type { CasStore } from "../cas/index.js";
|
||||
|
||||
// ❌ Bad — reaching past index.ts
|
||||
import { createCasStore } from "../cas/cas.js";
|
||||
|
||||
// ❌ Bad — re-exporting from non-index file
|
||||
// in engine/engine.ts:
|
||||
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
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Style | Example |
|
||||
@@ -137,10 +181,10 @@ 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();
|
||||
|
||||
@@ -197,12 +241,55 @@ Test files (`__tests__/**`) are exempt.
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
bun run check # biome check (lint + format)
|
||||
bun run check # tsc --build + biome check
|
||||
bun run format # biome format --write
|
||||
bun run build # full build
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
### Version Management & Publishing
|
||||
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||
|
||||
```bash
|
||||
# 1. After making changes, add a changeset describing the change
|
||||
bun changeset
|
||||
|
||||
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||
bun version
|
||||
|
||||
# 3. Build, test, and publish to npmjs
|
||||
bun release
|
||||
```
|
||||
|
||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||
- Each package has auto-generated `CHANGELOG.md`
|
||||
|
||||
### Consuming @uncaged/* Packages
|
||||
|
||||
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||
|
||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||
|
||||
```
|
||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||
│ bun release — build + test + changeset publish
|
||||
▼
|
||||
npmjs.org — @uncaged/* scoped packages (public)
|
||||
│ bun install
|
||||
▼
|
||||
my-workflows/ (workspace) — normal package.json
|
||||
│ bun run build:develop — bun build → single .esm.js
|
||||
▼
|
||||
uncaged-workflow workflow add — register bundle locally
|
||||
uncaged-workflow run — execute workflow
|
||||
```
|
||||
|
||||
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# @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).
|
||||
|
||||
## Core Concepts
|
||||
|
||||
| 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. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
|
||||
| **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. |
|
||||
|
||||
## 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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build all packages
|
||||
bun run build
|
||||
|
||||
# Register a workflow bundle
|
||||
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||
|
||||
# Run a workflow
|
||||
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run check # Biome lint + format check
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run tests
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
|
||||
+8
-2
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/node_modules",
|
||||
"!packages/workflow/workflow",
|
||||
"!xiaoju/scripts/bundle.ts"
|
||||
]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
|
||||
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
|
||||
import {
|
||||
buildDevelopDescriptor,
|
||||
developWorkflowDefinition,
|
||||
} from "./packages/workflow-template-develop/src/index.js";
|
||||
|
||||
const agent = createCursorAgent({
|
||||
command: "/home/azureuser/.local/bin/cursor-agent",
|
||||
model: "auto",
|
||||
timeout: 300_000,
|
||||
workspace: null,
|
||||
});
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
|
||||
@@ -0,0 +1,268 @@
|
||||
# Uncaged workflow — Architecture
|
||||
|
||||
**Last updated:** 2026-05-09
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||
|
||||
## Package map
|
||||
|
||||
Grouped by responsibility (npm name → folder).
|
||||
|
||||
| Layer | Package | One-line role |
|
||||
|-------|---------|----------------|
|
||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types and `Result` helpers; peer `zod` only — no other workspace deps. |
|
||||
| Author API | `@uncaged/workflow-runtime` → `workflow-runtime` | `createWorkflow` and re-exports of protocol workflow types for bundle authors. |
|
||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Base32/ULID, logger, storage root paths, global CAS dir, ref-field helpers. |
|
||||
| LLM plumbing | `@uncaged/workflow-reactor` → `workflow-reactor` | `createLlmFn`, `createThreadReactor`, and related tool-call types for threaded LLM invocation. |
|
||||
| CAS | `@uncaged/workflow-cas` → `workflow-cas` | `CasStore` implementation, XXH64 hashing, Merkle helpers over CAS payloads. |
|
||||
| Registry / bundles | `@uncaged/workflow-register` → `workflow-register` | Bundle validation & dynamic export extraction, `workflow.yaml` registry I/O, provider/model resolution. |
|
||||
| Engine | `@uncaged/workflow-execute` → `workflow-execute` | Thread execution, worker entry path, fork/GC, extract pipeline, `workflowAsAgent`. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
|
||||
| Agent adapters | `@uncaged/workflow-agent-cursor` → `workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
|
||||
| | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
|
||||
| | `@uncaged/workflow-agent-llm` → `workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
|
||||
| Agent shared | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
|
||||
| Templates | `@uncaged/workflow-template-develop` → `workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
|
||||
| | `@uncaged/workflow-template-solve-issue` → `workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
|
||||
| Dashboard | `@uncaged/workflow-dashboard` → `workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
|
||||
|
||||
## Dependency graph (workspace packages)
|
||||
|
||||
Bottom-up layering for the execution stack:
|
||||
|
||||
```mermaid
|
||||
flowchart BT
|
||||
subgraph L0["Layer 0 — contract"]
|
||||
protocol["@uncaged/workflow-protocol"]
|
||||
end
|
||||
subgraph L1["Layer 1 — on protocol"]
|
||||
runtime["@uncaged/workflow-runtime"]
|
||||
util["@uncaged/workflow-util"]
|
||||
reactor["@uncaged/workflow-reactor"]
|
||||
end
|
||||
subgraph L2["Layer 2 — protocol + util"]
|
||||
cas["@uncaged/workflow-cas"]
|
||||
register["@uncaged/workflow-register"]
|
||||
end
|
||||
subgraph L3["Layer 3 — engine"]
|
||||
execute["@uncaged/workflow-execute"]
|
||||
end
|
||||
subgraph L4["Layer 4 — CLI"]
|
||||
cli["@uncaged/cli-workflow"]
|
||||
end
|
||||
runtime --> protocol
|
||||
util --> protocol
|
||||
reactor --> protocol
|
||||
cas --> protocol
|
||||
cas --> util
|
||||
register --> protocol
|
||||
register --> util
|
||||
execute --> protocol
|
||||
execute --> runtime
|
||||
execute --> util
|
||||
execute --> cas
|
||||
execute --> reactor
|
||||
execute --> register
|
||||
cli --> protocol
|
||||
cli --> util
|
||||
cli --> cas
|
||||
cli --> execute
|
||||
cli --> register
|
||||
cli --> runtime
|
||||
```
|
||||
|
||||
**Adjacent consumers** (not in the main CLI stack):
|
||||
|
||||
- `@uncaged/workflow-util-agent` → `@uncaged/workflow-runtime`
|
||||
- `@uncaged/workflow-agent-llm` → `@uncaged/workflow-runtime`
|
||||
- `@uncaged/workflow-agent-cursor` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`, `zod`
|
||||
- `@uncaged/workflow-agent-hermes` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`
|
||||
- `@uncaged/workflow-template-develop` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod`
|
||||
- `@uncaged/workflow-template-solve-issue` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod` (dev-only workspace deps: `@uncaged/workflow-cas`, `@uncaged/workflow-execute` for tests/tooling per `package.json`)
|
||||
|
||||
## Package roles (detail)
|
||||
|
||||
- **`workflow-protocol`** — Pure types (`WorkflowFn`, contexts, `CasStore` interface, descriptor shapes), `START` / `END`, `ok` / `err`. Depends only on peer `zod` for schema-related types in signatures.
|
||||
- **`workflow-runtime`** — Workflow author surface: `createWorkflow` from `src/create-workflow.js`, re-exports protocol types/constants used when authoring bundles.
|
||||
- **`workflow-util`** — Cross-cutting utilities: Crockford Base32, ULID, `createLogger`, `getDefaultWorkflowStorageRoot`, `getGlobalCasDir`, ref normalization; re-exports `ok`/`err` from protocol.
|
||||
- **`workflow-cas`** — Filesystem CAS (`createCasStore`), `hashString` / `hashWorkflowBundleBytes`, Merkle node serialization and helpers (`merkle.js`).
|
||||
- **`workflow-register`** — Bundle pipeline (`validateWorkflowBundle`, `extractBundleExports`, descriptor builders), registry YAML read/write, `resolveModel` / `splitProviderModelRef`.
|
||||
- **`workflow-execute`** — `executeThread`, supervisor/worker wiring (`engine/`), fork/GC/pause gate, `createExtract` + LLM extract helpers (`extract/`), `workflowAsAgent`. Imports `@uncaged/workflow-reactor` for LLM-backed extract/supervisor paths (`extract-fn.ts`, `supervisor.ts`).
|
||||
- **`workflow-reactor`** — `createLlmFn`, `createThreadReactor`, and thread tool-invocation types — consumed by `workflow-execute`.
|
||||
- **`cli-workflow`** — CLI commands and HTTP/dashboard-related wiring (`hono`, `yaml`); composes register + execute + CAS + util.
|
||||
- **`workflow-agent-*`** — Replaceable `AgentFn` implementations (Cursor / Hermes CLIs, or HTTP LLM).
|
||||
- **`workflow-util-agent`** — Shared prompt assembly and subprocess spawning for CLI agents.
|
||||
- **`workflow-template-*`** — Concrete `WorkflowDefinition` graphs + Zod role schemas + descriptor builders for publishing bundles.
|
||||
- **`workflow-dashboard`** — Standalone React UI; no published library entry matching `src/index.ts`.
|
||||
|
||||
## Three-phase engine loop
|
||||
|
||||
Each role round is implemented in `packages/workflow-runtime/src/create-workflow.ts` (`advanceOneRound`): moderator → agent → extractor, with progressive context types from `@uncaged/workflow-protocol`.
|
||||
|
||||
```
|
||||
┌─→ Phase 1: MODERATOR
|
||||
│ Context: ModeratorContext { threadId, depth, start, steps }
|
||||
│ Action: moderator(ctx) → role name | END
|
||||
│
|
||||
│ Phase 2: AGENT
|
||||
│ Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
|
||||
│ Action: agent(ctx) → raw string
|
||||
│
|
||||
│ Phase 3: EXTRACTOR
|
||||
│ Context: ExtractContext = AgentCtx + { agentContent }
|
||||
│ Action: runtime.extract(schema, extractPrompt, ctx) → typed meta
|
||||
│
|
||||
│ Merge: RoleStep { role, contentHash, meta, refs, timestamp }
|
||||
│ Append to steps
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Context types (progressive)
|
||||
|
||||
Defined in `packages/workflow-protocol/src/types.ts`:
|
||||
|
||||
```typescript
|
||||
type ModeratorContext<M> = ThreadContext<M>;
|
||||
type AgentContext<M> = ModeratorContext<M> & {
|
||||
currentRole: { name: string; systemPrompt: string };
|
||||
};
|
||||
type ExtractContext<M> = AgentContext<M> & { agentContent: string };
|
||||
```
|
||||
|
||||
### Key properties
|
||||
|
||||
- **Moderator is synchronous and pure** — no I/O, no state mutation inside `createWorkflow`’s moderator call path.
|
||||
- **Agent receives `AgentContext`** — reads `ctx.currentRole.systemPrompt`; raw output becomes `agentContent` for extract.
|
||||
- **Extractor is `WorkflowRuntime.extract`** — supplied by the engine from registry-resolved LLM config (`workflow-execute`); stores agent body in CAS and yields `contentHash` + `refs` on each step (`create-workflow.ts`).
|
||||
- **`extractPrompt` is a call parameter** on `RoleDefinition`, not implicit context state.
|
||||
|
||||
## Agent information sources
|
||||
|
||||
An agent has exactly three information sources:
|
||||
|
||||
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 obtains it via `ExtractFn` (e.g. Cursor agent).
|
||||
|
||||
## Bundle contract
|
||||
|
||||
A workflow bundle is a single `.esm.js` file with two named exports (see `WorkflowFn` / `WorkflowDescriptor` in `packages/workflow-protocol/src/types.ts`):
|
||||
|
||||
```typescript
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowFn;
|
||||
|
||||
type WorkflowFn = (
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||
```
|
||||
|
||||
`RoleOutput` carries `contentHash`, `meta`, and `refs` (agent text lives in CAS, addressed by hash).
|
||||
|
||||
### Constraints
|
||||
|
||||
- Single `.esm.js` file
|
||||
- No dynamic `import()` in bundles (loader exempt in engine)
|
||||
- Portable bundle static imports are constrained by validation in `@uncaged/workflow-register` (`validateWorkflowBundle`)
|
||||
- XXH64 hash (Crockford Base32) = version ID
|
||||
|
||||
### Why AsyncGenerator?
|
||||
|
||||
- Each `yield` lets `workflow-execute` persist state, CAS rows, and enforce pause/abort
|
||||
- `return` supplies `WorkflowCompletion`
|
||||
- Fork replays historical steps into a new thread context
|
||||
- Bundle does not import the engine — only protocol/runtime types at build time
|
||||
|
||||
## Storage layout
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/
|
||||
├── cas/ # Global content-addressed blobs (see getGlobalCasDir)
|
||||
├── bundles/
|
||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||
│ ├── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
|
||||
│ └── C9NMV6V2TQT81/ # Per-hash bundle dir (alongside or instead of loose files)
|
||||
│ ├── threads.json # Active threads: threadId → { head, start, updatedAt }
|
||||
│ └── history/
|
||||
│ └── 2026-05-09.jsonl # Completed threads (one JSON object per line)
|
||||
├── logs/ # One folder per bundle hash
|
||||
│ └── C9NMV6V2TQT81/
|
||||
│ ├── 01KQXKW…YG.running # Present while worker executes this thread (optional)
|
||||
│ └── 01KQXKW…YG.info.jsonl # Debug log
|
||||
└── workflow.yaml # Registry
|
||||
```
|
||||
|
||||
### 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)
|
||||
|
||||
### Registry (`workflow.yaml`)
|
||||
|
||||
Managed by `@uncaged/workflow-register` (`readWorkflowRegistry`, `writeWorkflowRegistry`, …). Shape includes workflow entries and a top-level `config` section used for extract/supervisor model resolution.
|
||||
|
||||
### Thread storage (CAS + index)
|
||||
|
||||
Thread execution state is a chain of immutable CAS nodes (`StartNode`, `StateNode`, content Merkle blobs). Per bundle:
|
||||
|
||||
- **`threads.json`** — only in-flight threads (`head`, `start`, `updatedAt`).
|
||||
- **`history/{YYYY-MM-DD}.jsonl`** — completed threads (`threadId`, `head`, `start`, `completedAt`).
|
||||
- **CAS (`cas/`)** — payloads and refs for replay, GC, and fork sharing.
|
||||
|
||||
**`.info.jsonl`** — Structured debug log via `@uncaged/workflow-util` `createLogger`:
|
||||
|
||||
```jsonc
|
||||
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
|
||||
```
|
||||
|
||||
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → code location.
|
||||
|
||||
## Execution model
|
||||
|
||||
- **No daemon.** `uncaged-workflow run <name>` starts a worker process (`workflow-execute` worker entry via `getWorkerHostScriptPath`)
|
||||
- Threads share bundle-scoped workers as implemented in CLI/engine
|
||||
- Pause/resume/abort via engine IPC and pause gate (`createThreadPauseGate`)
|
||||
|
||||
## CLI commands
|
||||
|
||||
| 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 |
|
||||
|
||||
## 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; types live in `workflow-protocol` |
|
||||
| **`WorkflowRuntime.extract` + CAS `contentHash`** | Large agent bodies deduplicated globally; Merkle roots summarize threads |
|
||||
| **`workflow-reactor` split** | LLM tool-calling loop isolated from filesystem/registry concerns |
|
||||
| **Single-file ESM** | Hash = version, self-contained bundle |
|
||||
| **No daemon** | OS handles process lifecycle |
|
||||
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||
@@ -0,0 +1,317 @@
|
||||
# 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.
|
||||
|
||||
**Architecture:** Migrate CAS from thread-local to global (`~/.uncaged/workflow/cas/`), add `refs` to RoleStep for GC traceability, then build `workflowAsAgent(name)` factory that resolves workflow name → bundle via registry and spawns a child thread.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun, Zod v4, monorepo with `packages/`
|
||||
|
||||
**Issue:** https://git.shazhou.work/uncaged/workflow/issues/25
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Global CAS Migration
|
||||
|
||||
Move CAS storage from `<threadDir>/<threadId>.cas/` to `~/.uncaged/workflow/cas/` (global, content-addressed, immutable). This is a **breaking change** — thread-local `.cas/` directories are abandoned.
|
||||
|
||||
### Task 1.1: Add `globalCasDir` helper to `storage-root.ts`
|
||||
|
||||
**Objective:** Provide a single function that returns the global CAS directory path.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/storage-root.ts`
|
||||
- Test: `packages/workflow/__tests__/storage-root.test.ts`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
// storage-root.ts — add export
|
||||
export function getGlobalCasDir(storageRoot?: string): string {
|
||||
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
|
||||
return join(root, "cas");
|
||||
}
|
||||
```
|
||||
|
||||
Export from `packages/workflow/src/index.ts`.
|
||||
|
||||
### Task 1.2: Update `cmd-cas.ts` to use global CAS
|
||||
|
||||
**Objective:** CLI `cas get/put/list/rm` no longer needs threadId for storage location — CAS is global. But keep threadId in CLI for backward compat of planner/coder prompts (they pass threadId).
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/cli-workflow/src/cmd-cas.ts`
|
||||
|
||||
**Changes:**
|
||||
- `resolveCasDir` → use `getGlobalCasDir(storageRoot)` instead of deriving from thread data path
|
||||
- `cmdCasPut` / `cmdCasGet` / `cmdCasList` / `cmdCasRm`: threadId is still accepted (prompts pass it) but storage goes to global dir
|
||||
- Remove the `resolveThreadDataPath` dependency for CAS operations — thread doesn't need to exist to read CAS
|
||||
|
||||
```typescript
|
||||
import { createThreadCas, getGlobalCasDir } from "@uncaged/workflow";
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
_threadId: string, // kept for CLI compat, not used for path
|
||||
hash: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createThreadCas(getGlobalCasDir(storageRoot));
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return err(`cas entry not found: ${hash}`);
|
||||
}
|
||||
return ok(content);
|
||||
}
|
||||
// ... same pattern for put/list/rm
|
||||
```
|
||||
|
||||
### Task 1.3: Update `cmd-thread.ts` — thread rm no longer deletes `.cas/`
|
||||
|
||||
**Objective:** Since CAS is global, `thread rm` should NOT delete CAS entries. CAS cleanup is GC's job.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/cli-workflow/src/cmd-thread.ts`
|
||||
- Check: remove any `rmdir` / `unlink` of `<threadId>.cas/` directory
|
||||
|
||||
### Task 1.4: Rename `createThreadCas` → `createCasStore`
|
||||
|
||||
**Objective:** The name `createThreadCas` is misleading now. Rename to `createCasStore`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/cas.ts` — rename function
|
||||
- Modify: `packages/workflow/src/index.ts` — update export (keep `createThreadCas` as deprecated alias for one release)
|
||||
- Modify: all consumers (`cmd-cas.ts`)
|
||||
|
||||
### Task 1.5: Update tests
|
||||
|
||||
**Objective:** All CAS-related tests use global dir instead of thread-local.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/cli-workflow/__tests__/commands.test.ts`
|
||||
- Verify: `bun test` passes
|
||||
|
||||
### Task 1.6: Clean up old thread-local `.cas/` references
|
||||
|
||||
**Objective:** Remove dead code that creates/reads thread-local `.cas/` directories.
|
||||
|
||||
**Files:**
|
||||
- Search all `*.ts` for `.cas` path construction patterns
|
||||
- Remove orphaned helpers
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: RoleStep `refs` Tracking
|
||||
|
||||
Add `refs: string[]` to persisted role steps so GC can trace which CAS entries are alive.
|
||||
|
||||
### Task 2.1: Add `refs` to `RoleOutput` and engine persistence
|
||||
|
||||
**Objective:** Every role step can declare which CAS hashes it produced or consumed.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/types.ts`
|
||||
- Modify: `packages/workflow/src/engine.ts`
|
||||
|
||||
**Changes to `types.ts`:**
|
||||
|
||||
```typescript
|
||||
export type RoleOutput = {
|
||||
role: string;
|
||||
content: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[]; // CAS hashes produced/consumed by this step
|
||||
};
|
||||
```
|
||||
|
||||
**Changes to `engine.ts`:**
|
||||
- `appendDataLine` for role steps: include `refs` field (default `[]` if not provided)
|
||||
|
||||
### Task 2.2: Auto-populate refs from meta hashes
|
||||
|
||||
**Objective:** The engine should automatically extract CAS hashes from `meta` to populate `refs`, so roles don't need to manually track them.
|
||||
|
||||
**Strategy:** After meta extraction, walk the meta object and collect any string that looks like a CAS hash (Crockford Base32, 13 chars). This is a heuristic but works because CAS hashes are distinctive.
|
||||
|
||||
Alternative (simpler): Let each `RoleDefinition` optionally declare a `extractRefs(meta: M) => string[]` function. For planner, this returns `meta.phases.map(p => p.hash)`. For coder, `[meta.completedPhase]`.
|
||||
|
||||
**Recommended:** The explicit `extractRefs` approach — no magic, no false positives.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/types.ts` — add optional `extractRefs` to `RoleDefinition`
|
||||
- Modify: `packages/workflow/src/create-workflow.ts` — call `extractRefs` after meta extraction, set on `RoleOutput.refs`
|
||||
- Modify: `packages/workflow-role-planner/src/planner.ts` — implement `extractRefs`
|
||||
- Modify: `packages/workflow-role-coder/src/coder.ts` — implement `extractRefs`
|
||||
|
||||
```typescript
|
||||
// types.ts — RoleDefinition addition
|
||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
extractPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs?: (meta: Meta) => string[]; // CAS hashes to track
|
||||
};
|
||||
|
||||
// planner.ts
|
||||
extractRefs: (meta) => meta.phases.map(p => p.hash),
|
||||
|
||||
// coder.ts
|
||||
extractRefs: (meta) => [meta.completedPhase],
|
||||
```
|
||||
|
||||
### Task 2.3: Update fork logic to preserve refs
|
||||
|
||||
**Objective:** When forking a thread, `refs` from historical steps must be carried over.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/fork-thread.ts`
|
||||
- Verify: `ForkHistoricalStep` / `PrefilledDiskStep` include `refs`
|
||||
|
||||
### Task 2.4: Tests for refs tracking
|
||||
|
||||
**Files:**
|
||||
- Add: `packages/workflow/__tests__/refs-tracking.test.ts`
|
||||
- Verify: refs appear in `.data.jsonl` output
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: CAS Garbage Collection
|
||||
|
||||
### Task 3.1: Implement `gc.ts` in `@uncaged/workflow`
|
||||
|
||||
**Objective:** Mark-and-sweep GC — scan all thread `.data.jsonl` files, collect `refs`, delete orphaned CAS entries.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/src/gc.ts`
|
||||
- Export from: `packages/workflow/src/index.ts`
|
||||
|
||||
```typescript
|
||||
export type GcResult = {
|
||||
scannedThreads: number;
|
||||
activeRefs: number;
|
||||
deletedEntries: number;
|
||||
deletedHashes: string[];
|
||||
};
|
||||
|
||||
export async function garbageCollectCas(storageRoot: string): Promise<GcResult> {
|
||||
// 1. Find all .data.jsonl files under storageRoot
|
||||
// 2. Parse each, flatMap step.refs → Set<string>
|
||||
// 3. List all CAS entries via createCasStore(globalCasDir).list()
|
||||
// 4. Delete entries not in active set
|
||||
// 5. Return stats
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3.2: Add `uncaged-workflow gc` CLI command
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/cli-workflow/src/cmd-gc.ts`
|
||||
- Modify: `packages/cli-workflow/src/cli-dispatch.ts` — add `gc` subcommand
|
||||
|
||||
### Task 3.3: Run GC on `thread rm`
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/cli-workflow/src/cmd-thread.ts` — after deleting thread data, optionally run GC
|
||||
|
||||
### Task 3.4: Tests for GC
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/cli-workflow/__tests__/gc-cli.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: `workflowAsAgent` Factory
|
||||
|
||||
### Task 4.1: Create `workflowAsAgent` in `@uncaged/workflow`
|
||||
|
||||
**Objective:** Factory function that takes a workflow name, resolves to bundle, returns an `AgentFn`.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/src/workflow-as-agent.ts`
|
||||
- Export from: `packages/workflow/src/index.ts`
|
||||
|
||||
```typescript
|
||||
import type { AgentFn } from "./types.js";
|
||||
|
||||
export type WorkflowAsAgentOptions = {
|
||||
storageRoot?: string;
|
||||
};
|
||||
|
||||
export function workflowAsAgent(
|
||||
workflowName: string,
|
||||
options?: WorkflowAsAgentOptions,
|
||||
): AgentFn {
|
||||
return async (ctx) => {
|
||||
const storageRoot = options?.storageRoot ?? getDefaultWorkflowStorageRoot();
|
||||
|
||||
// 1. Read registry → resolve name to bundle hash + path
|
||||
const registry = await readWorkflowRegistry(storageRoot);
|
||||
const entry = getRegisteredWorkflow(registry, workflowName);
|
||||
if (entry === null) {
|
||||
return `ERROR: workflow "${workflowName}" not found in registry`;
|
||||
}
|
||||
|
||||
// 2. Load bundle
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExports = await extractBundleExports(bundlePath);
|
||||
|
||||
// 3. Create child thread input from ctx.start.content (parent prompt)
|
||||
const input: ThreadInput = {
|
||||
prompt: ctx.start.content,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
// 4. Generate child threadId
|
||||
const childThreadId = generateUlid();
|
||||
|
||||
// 5. Execute — collect all yields, return final content
|
||||
const io: ExecuteThreadIo = { ... };
|
||||
const result = await executeThread(bundleExports.run, workflowName, input, ...);
|
||||
|
||||
// 6. Return summary as agent content
|
||||
return result.summary;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Task 4.2: System-level depth limit
|
||||
|
||||
**Objective:** Prevent infinite recursion. Track depth via thread metadata, enforce a global max (default 3, configurable in `workflow.yaml`).
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/types.ts` — add `depth` to `WorkflowFnOptions`
|
||||
- Modify: `packages/workflow/src/workflow-as-agent.ts` — increment depth, check limit
|
||||
- Modify: registry or config types for `maxDepth` setting
|
||||
|
||||
### Task 4.3: Tests for workflowAsAgent
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/__tests__/workflow-as-agent.test.ts`
|
||||
- Test: name resolution, depth limit, child thread execution
|
||||
|
||||
### Task 4.4: Integration test — nested workflow
|
||||
|
||||
**Objective:** Create a minimal test workflow that calls another workflow via `workflowAsAgent`.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/__tests__/workflow-as-agent-integration.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
```
|
||||
Phase 1 (Global CAS) → Phase 2 (refs) → Phase 3 (GC) → Phase 4 (workflowAsAgent)
|
||||
```
|
||||
|
||||
Each phase is independently mergeable. Phase 3 depends on Phase 2 (needs refs to know what's alive). Phase 4 depends on Phase 1 (global CAS for cross-thread sharing).
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- CAS storage location moves from `<thread>.cas/` to `~/.uncaged/workflow/cas/`
|
||||
- `RoleOutput` gains required `refs: string[]` field
|
||||
- Existing threads with thread-local CAS will lose access to old CAS data (acceptable — those are short-lived workflow artifacts)
|
||||
- `createThreadCas` renamed to `createCasStore` (alias kept temporarily)
|
||||
@@ -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. |
|
||||
@@ -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**
|
||||
@@ -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 |
|
||||
@@ -1,438 +0,0 @@
|
||||
# RFC-001: Workflow Engine Design
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Date:** 2026-05-06
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Package Structure
|
||||
|
||||
| Package | npm Name | Binary |
|
||||
|---------|----------|--------|
|
||||
| Core lib | `@uncaged/workflow` | — |
|
||||
| CLI | `@uncaged/cli-workflow` | `uncaged-workflow` |
|
||||
|
||||
Future: `@uncaged/cli` umbrella, invoke via `uncaged workflow <subcommand>`.
|
||||
|
||||
Monorepo uses **bun workspace**.
|
||||
|
||||
## 2. Workflow Physical Implementation
|
||||
|
||||
A **Workflow** is a single-file ESM module that **named-exports** an **AsyncGenerator** function as `run` and workflow metadata as `descriptor`:
|
||||
|
||||
```typescript
|
||||
/** What each yield produces — one role's output. */
|
||||
type RoleOutput = {
|
||||
role: string;
|
||||
content: string;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** What the generator returns when done. */
|
||||
type WorkflowResult = {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/** Input to a workflow — prompt + optional historical steps for fork/resume. */
|
||||
type ThreadInput = {
|
||||
prompt: string;
|
||||
steps: RoleOutput[]; // [] for new thread, pre-filled for fork/resume
|
||||
};
|
||||
|
||||
/** The bundle contract — an AsyncGenerator, not a Promise. */
|
||||
type WorkflowFn = (
|
||||
input: ThreadInput,
|
||||
options: { isDryRun: boolean; maxRounds: number }
|
||||
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
||||
```
|
||||
|
||||
### Why AsyncGenerator?
|
||||
|
||||
The workflow **yields** each role output instead of writing to an injected writer or
|
||||
exporting a framework-specific shape:
|
||||
|
||||
```typescript
|
||||
// Example bundle — zero framework dependency (named exports only)
|
||||
export const descriptor = {
|
||||
description: "Fix auth bug",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Plans the fix",
|
||||
schema: { type: "object", properties: { files: { type: "array", items: { type: "string" } } } },
|
||||
},
|
||||
coder: {
|
||||
description: "Implements the plan",
|
||||
schema: { type: "object", properties: { diff: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const run = async function* (input, options) {
|
||||
const plan = await callLLM("plan: " + input.prompt);
|
||||
yield { role: "planner", content: plan, meta: { files: ["src/auth.ts"] } };
|
||||
|
||||
const code = await callLLM("implement: " + plan);
|
||||
yield { role: "coder", content: code, meta: { diff: "..." } };
|
||||
|
||||
return { returnCode: 0, summary: "Fixed auth bug" };
|
||||
};
|
||||
```
|
||||
|
||||
**Engine controls the loop**, not the bundle:
|
||||
- Each `yield` → engine writes to `.data.jsonl`, checks `AbortSignal`, handles pause/resume
|
||||
- `return` → engine writes the final result, marks thread complete
|
||||
- **Fork** = read historical steps from `.data.jsonl`, pass as `input.steps` to a new generator
|
||||
- **Zero injection** — the bundle doesn't import or receive anything from the engine
|
||||
|
||||
### Fork/Resume via ThreadInput
|
||||
|
||||
When using the `createRoleModerator` helper, fork is **naturally handled**:
|
||||
|
||||
```typescript
|
||||
// The moderator receives ThreadContext with historical steps
|
||||
// It sees planner already ran → routes to coder automatically
|
||||
const gen = workflow(
|
||||
{ prompt: "fix bug #3", steps: [{ role: "planner", content: "...", meta: {} }] },
|
||||
{ isDryRun: false, maxRounds: 10 }
|
||||
);
|
||||
// First yield will be coder's output, not planner's
|
||||
```
|
||||
|
||||
No special replay logic needed — the moderator/role pattern inherently supports
|
||||
resuming from any snapshot, because moderator routing is a pure function of the
|
||||
accumulated steps.
|
||||
|
||||
This follows the **Dependency Inversion Principle**: the engine depends on the
|
||||
generator protocol (a language primitive), not on a framework-specific `WorkflowDefinition`.
|
||||
Bundles remain pure functions with no coupling to `@uncaged/workflow`.
|
||||
|
||||
### Relationship to Role/Moderator Pattern
|
||||
|
||||
The Role + Moderator pattern from Section 8 is one **implementation strategy** inside a
|
||||
bundle, not the bundle contract itself. A helper like `createRoleModerator(roles, moderator)`
|
||||
can produce the AsyncGenerator internally, but simple workflows can yield directly without
|
||||
any framework types.
|
||||
|
||||
### Constraints
|
||||
|
||||
- Single `.esm.js` file
|
||||
- Named exports `run` (callable AsyncGenerator workflow) and `descriptor` (metadata object)
|
||||
- No default export
|
||||
- No dynamic `import()`
|
||||
- All static imports must be Node built-in modules only
|
||||
|
||||
This guarantees the file is self-contained, and its **XXH64 hash** (encoded as Crockford Base32) serves as a globally unique version identifier.
|
||||
|
||||
### Role Descriptor (`export const descriptor`)
|
||||
|
||||
The bundle **must** export a `descriptor` object describing roles for tooling/agent consumption.
|
||||
|
||||
Shape: `{ description: string, roles: Record<string, { description: string, schema: JSONSchema }> }`
|
||||
|
||||
When you register a bundle via `uncaged-workflow add`, the engine imports the module, validates `descriptor`, and writes `{hash}.yaml` next to `{hash}.esm.js` under `bundles/` (same serialized shape as below):
|
||||
|
||||
```yaml
|
||||
description: "Workflow brief introduction"
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes the issue and creates a plan"
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
plan:
|
||||
type: string
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
coder:
|
||||
description: "Implements the plan"
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
diff:
|
||||
type: string
|
||||
```
|
||||
|
||||
Execution uses `run` only; YAML is for tooling and introspection.
|
||||
|
||||
## 3. Storage Layout
|
||||
|
||||
All data lives under `~/.uncaged/workflow/`:
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/
|
||||
├── bundles/ # ESM bundles
|
||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64 hash
|
||||
│ └── C9NMV6V2TQT81.yaml # Role descriptor (from bundle export, at register time)
|
||||
├── logs/ # Thread data, one folder per bundle hash
|
||||
│ └── C9NMV6V2TQT81/
|
||||
│ ├── 01KQXKW18CT8G75T53R8F4G7YG.data.jsonl
|
||||
│ └── 01KQXKW18CT8G75T53R8F4G7YG.info.jsonl
|
||||
└── workflow.yaml # Registry
|
||||
```
|
||||
|
||||
**Not** a git repo. **Not** an npm package. Bundles are self-contained single files.
|
||||
|
||||
### ID Encoding
|
||||
|
||||
All IDs use **Crockford Base32**:
|
||||
- Better readability than Base64
|
||||
- Higher density than hex (shorter filenames)
|
||||
- ULID: 10 chars timestamp (high 2 bits zero-padded for future use) + 16 chars random
|
||||
|
||||
## 4. Registry (`workflow.yaml`)
|
||||
|
||||
```yaml
|
||||
workflows:
|
||||
solve-issue:
|
||||
hash: "C9NMV6V2TQT81"
|
||||
timestamp: 1714963200000
|
||||
history:
|
||||
- hash: "A7BKR3M1NPQ40"
|
||||
timestamp: 1714876800000
|
||||
- hash: "X2FGH8J4KLM56"
|
||||
timestamp: 1714790400000
|
||||
```
|
||||
|
||||
Type:
|
||||
|
||||
```typescript
|
||||
{
|
||||
workflows: Record<string, {
|
||||
hash: string; // Crockford Base32 of current XXH64
|
||||
timestamp: number;
|
||||
history: { hash: string; timestamp: number }[];
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
No concurrency control or timeout settings in the registry — those belong to each workflow/role/adapter.
|
||||
|
||||
## 5. Thread JSONL Format
|
||||
|
||||
### `.data.jsonl` — Thread State
|
||||
|
||||
**Line 1: Start record**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "solve-issue",
|
||||
"hash": "C9NMV6V2TQT81",
|
||||
"threadId": "01KQXKW18CT8G75T53R8F4G7YG",
|
||||
"parameters": {
|
||||
"prompt": "Fix the login redirect bug in #3",
|
||||
"options": {
|
||||
"isDryRun": false,
|
||||
"maxRounds": 5
|
||||
}
|
||||
},
|
||||
"timestamp": 1714963200000
|
||||
}
|
||||
```
|
||||
|
||||
**Line 2+: Role outputs**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"role": "planner",
|
||||
"content": "Plan: modify auth middleware...",
|
||||
"meta": { "plan": "...", "files": ["src/auth.ts"] },
|
||||
"timestamp": 1714963201000
|
||||
}
|
||||
```
|
||||
|
||||
### `.info.jsonl` — Debug Log
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tag": "4KNMR2PX", // 40-bit random, Crockford Base32 (8 chars)
|
||||
"content": "Loading workflow bundle...",
|
||||
"timestamp": 1714963200500
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Execution Model
|
||||
|
||||
- **No daemon.** `uncaged-workflow run <name>` starts a worker process.
|
||||
- Same bundle's threads share one process (memory efficiency).
|
||||
- Process exits automatically when all threads complete.
|
||||
- Thread termination requires **IPC** within the process (not just kill PID).
|
||||
|
||||
## 7. CLI Requirements
|
||||
|
||||
### P1 (Must Have)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uncaged-workflow add <name> <file.esm.js> [--types <path>]` | Register a compiled `.esm.js` bundle (descriptor extracted from `export const descriptor`) |
|
||||
| `uncaged-workflow list` | List registered workflows |
|
||||
| `uncaged-workflow show <name>` | Show workflow details |
|
||||
| `uncaged-workflow remove <name>` | Remove a workflow |
|
||||
| `uncaged-workflow run <name> [--prompt] [--dry-run] [--max-rounds]` | Start a thread |
|
||||
| `uncaged-workflow threads [name]` | List threads (optionally filter by workflow) |
|
||||
| `uncaged-workflow thread <id>` | Show thread state |
|
||||
| `uncaged-workflow thread rm <id>` | Delete a thread |
|
||||
| `uncaged-workflow ps` | List running threads |
|
||||
| `uncaged-workflow kill <thread-id>` | Terminate a running thread (via IPC) |
|
||||
|
||||
### P2 (Should Have)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uncaged-workflow history <name>` | Show version history |
|
||||
| `uncaged-workflow rollback <name> [hash]` | Switch to a previous version |
|
||||
| `uncaged-workflow pause <thread-id>` | Pause a running thread |
|
||||
| `uncaged-workflow resume <thread-id>` | Resume a paused thread |
|
||||
|
||||
### P3 (Nice to Have)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uncaged-workflow fork <thread-id> [--from-role <role>]` | Fork from a historical thread state |
|
||||
|
||||
## 8. Role/Moderator Pattern (Helper, Not Contract)
|
||||
|
||||
The bundle contract is the AsyncGenerator from Section 2. The Role + Moderator pattern
|
||||
below is a **convenience helper** for the common case of multi-role workflows with a
|
||||
routing function. It lives in `@uncaged/workflow` as an optional utility.
|
||||
|
||||
### Helper Function
|
||||
|
||||
```typescript
|
||||
function createRoleModerator<M extends RoleMeta>(
|
||||
def: { roles: { [K in keyof M & string]: Role<M[K]> }; moderator: Moderator<M> }
|
||||
): WorkflowFn; // returns (input: ThreadInput, options) => AsyncGenerator
|
||||
```
|
||||
|
||||
Usage in a bundle:
|
||||
|
||||
```typescript
|
||||
import { createRoleModerator, END } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "Example multi-role workflow",
|
||||
roles: {
|
||||
planner: { description: "Plans work", schema: {} },
|
||||
coder: { description: "Writes code", schema: {} },
|
||||
},
|
||||
};
|
||||
|
||||
export const run = createRoleModerator({
|
||||
roles: { planner, coder },
|
||||
moderator(ctx) { return ctx.steps.length === 0 ? "planner" : END; },
|
||||
});
|
||||
// Accepts ThreadInput — fork with pre-filled steps works automatically
|
||||
```
|
||||
|
||||
### Supporting Types
|
||||
|
||||
```typescript
|
||||
/** Sentinel values for automaton control flow. */
|
||||
const START = "__start__" as const;
|
||||
const END = "__end__" as const;
|
||||
|
||||
/** Maps role names → their meta types. Single generic drives all inference. */
|
||||
type RoleMeta = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** Typed output of a Role execution. */
|
||||
type RoleResult<Meta> = { content: string; meta: Meta };
|
||||
|
||||
/** Engine start frame: initial prompt + thread identity. */
|
||||
type StartStep = {
|
||||
role: START;
|
||||
content: string; // the user prompt
|
||||
meta: { maxRounds: number; threadId: string };
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
/** A completed role step in the thread. */
|
||||
type RoleStep<M extends RoleMeta> = {
|
||||
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
|
||||
}[keyof M & string];
|
||||
|
||||
/** Thread-scoped context passed to roles and moderator. */
|
||||
type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||
threadId: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A Role — receives full thread context, returns typed content + meta.
|
||||
* Implementation can be an agent, LLM call, script, HTTP request, etc.
|
||||
*/
|
||||
type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>;
|
||||
|
||||
/**
|
||||
* An Agent — raw string output interface for LLM/CLI adapters.
|
||||
* Structured meta is extracted by the role's extract layer.
|
||||
*/
|
||||
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
* The Moderator — a pure routing function.
|
||||
* Receives the full thread context (start + all prior steps).
|
||||
* On initial call, `steps` is empty.
|
||||
* Returns the next role name or END to terminate.
|
||||
*/
|
||||
type Moderator<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END;
|
||||
|
||||
/** Complete workflow definition as authored by users. */
|
||||
type WorkflowDefinition<M extends RoleMeta> = {
|
||||
name: string;
|
||||
roles: { [K in keyof M & string]: Role<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
```
|
||||
|
||||
### Execution Flow (when using createRoleModerator)
|
||||
|
||||
```
|
||||
START (prompt) → Moderator → Role A → Moderator → Role B → ... → Moderator → END
|
||||
```
|
||||
|
||||
1. Engine creates a `StartStep` with the user prompt and maxRounds
|
||||
2. Moderator is called with `steps = []`, returns the first role name
|
||||
3. Role executes, appends a `RoleStep` to the thread
|
||||
4. Moderator is called again with updated steps, returns next role or END
|
||||
5. Repeat until END or maxRounds reached
|
||||
|
||||
### Responsibilities
|
||||
|
||||
| Component | Responsibility | Purity |
|
||||
|-----------|---------------|--------|
|
||||
| **Moderator** | Route to next role based on thread state | Pure function, no side effects |
|
||||
| **Role** | Execute a step (call LLM, run script, etc.) | Async, may have side effects |
|
||||
| **AgentFn** | Low-level LLM/CLI invocation adapter | Async, side effects |
|
||||
|
||||
### Key Constraints
|
||||
|
||||
- Moderator is **synchronous and pure** — no I/O, no state mutation
|
||||
- Roles receive the **full thread context** (not just the last message)
|
||||
- Round count = `steps.length`; max rounds in `start.meta.maxRounds`
|
||||
- The `meta` field on each step is **typed per role** via the `RoleMeta` generic
|
||||
|
||||
## 9. Design Decisions & Rationale
|
||||
|
||||
### Why single-file ESM?
|
||||
- Hash = version. No ambiguity.
|
||||
- No dependency hell. Self-contained.
|
||||
- Simple to distribute, store, and verify.
|
||||
|
||||
### Why no daemon?
|
||||
- Unnecessary complexity for process-per-bundle model.
|
||||
- OS process management (systemd, etc.) handles restarts.
|
||||
- IPC within process handles thread lifecycle.
|
||||
|
||||
### Why Crockford Base32?
|
||||
- Case-insensitive, filesystem-safe.
|
||||
- No ambiguous characters (0/O, 1/I/L).
|
||||
- More compact than hex (13 chars for 64-bit vs 16).
|
||||
|
||||
### Why not control concurrency in registry?
|
||||
- Different workflows have different constraints.
|
||||
- Same workflow may allow cross-project concurrency but not intra-project.
|
||||
- Concurrency belongs at workflow/role/adapter level.
|
||||
@@ -1,31 +0,0 @@
|
||||
import { createRoleModerator, END, type Role } from "@uncaged/workflow";
|
||||
|
||||
type Roles = {
|
||||
greeter: { greeting: string };
|
||||
};
|
||||
|
||||
export const descriptor = {
|
||||
description: "A simple hello world workflow",
|
||||
roles: {
|
||||
greeter: {
|
||||
description: "Generates a greeting",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { greeting: { type: "string" } },
|
||||
required: ["greeting"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const greeter: Role<Roles["greeter"]> = async (ctx) => ({
|
||||
content: `Hello, ${ctx.start.content}`,
|
||||
meta: { greeting: "Hello!" },
|
||||
});
|
||||
|
||||
export const run = createRoleModerator<Roles>({
|
||||
roles: { greeter },
|
||||
moderator(ctx) {
|
||||
return ctx.steps.length === 0 ? "greeter" : END;
|
||||
},
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-examples",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*"
|
||||
}
|
||||
}
|
||||
+13
-6
@@ -2,16 +2,23 @@
|
||||
"name": "@uncaged/workflow-monorepo",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"examples"
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun run --filter '*' build",
|
||||
"check": "biome check .",
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test"
|
||||
"test": "bun run --filter '*' test",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14"
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"bun-types": "^1.3.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,76 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
|
||||
|
||||
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uncaged-workflow workflow list
|
||||
uncaged-workflow run <name> --prompt "Your task"
|
||||
uncaged-workflow thread show <id>
|
||||
uncaged-workflow skill
|
||||
```
|
||||
|
||||
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
|
||||
|
||||
```
|
||||
uncaged-workflow — workflow engine CLI
|
||||
|
||||
Workflow registry:
|
||||
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
|
||||
workflow list List all registered workflows
|
||||
workflow show <name> Show details of a registered workflow
|
||||
workflow rm <name> Remove a workflow from the registry
|
||||
workflow history <name> Show version history of a workflow
|
||||
workflow rollback <name> [hash] Rollback a workflow to a previous version
|
||||
|
||||
Thread execution:
|
||||
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
|
||||
thread list [name] List threads, optionally filtered by workflow name
|
||||
thread show <id> Show thread details and state
|
||||
thread rm <id> Remove a thread
|
||||
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
|
||||
thread ps List running threads
|
||||
thread kill <thread-id> Kill a running thread
|
||||
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
|
||||
thread pause <thread-id> Pause a running thread
|
||||
thread resume <thread-id> Resume a paused thread
|
||||
|
||||
Content-addressable storage:
|
||||
cas get <hash> Retrieve content by hash from CAS
|
||||
cas put <content> Store content in CAS, prints hash
|
||||
cas list List all hashes in CAS
|
||||
cas rm <hash> Remove a CAS entry by hash
|
||||
cas gc Garbage-collect unreferenced CAS entries
|
||||
|
||||
Development:
|
||||
init workspace <name> Initialize a new workflow workspace
|
||||
init template <name> Initialize a new workflow template
|
||||
|
||||
Shortcuts:
|
||||
run <name> [...] → thread run
|
||||
live <id> [...] → thread live
|
||||
|
||||
Reference:
|
||||
skill [topic] Agent-consumable docs (cli, develop, author)
|
||||
|
||||
Use <command> --help for subcommand details.
|
||||
|
||||
Environment variables:
|
||||
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
|
||||
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts` → `runCli` in `src/cli-dispatch.js`.
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParsedAddArgv } from "../src/cmd-add.js";
|
||||
import type { ParsedAddArgv } from "../src/commands/workflow/index.js";
|
||||
|
||||
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
|
||||
return { name, filePath, typesPath: null };
|
||||
|
||||
@@ -2,19 +2,31 @@ 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 { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdHistory } from "../src/cmd-history.js";
|
||||
import { cmdList, formatListLines } from "../src/cmd-list.js";
|
||||
import { cmdRemove } from "../src/cmd-remove.js";
|
||||
import { cmdRollback } from "../src/cmd-rollback.js";
|
||||
import { cmdShow } from "../src/cmd-show.js";
|
||||
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,
|
||||
cmdHistory,
|
||||
cmdList,
|
||||
cmdRemove,
|
||||
cmdRollback,
|
||||
cmdShow,
|
||||
formatListLines,
|
||||
} from "../src/commands/workflow/index.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
describe("cli workflow commands", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -40,11 +52,13 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input) {
|
||||
export const run = async function* (input, options) {
|
||||
fs.existsSync(".");
|
||||
yield { role: "noop", content: input.prompt, meta: { done: true } };
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
`,
|
||||
@@ -111,8 +125,8 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
const bundlePath = join(storageRoot, "solo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`export const run = async function* (input) {
|
||||
yield { role: "x", content: input.prompt, meta: {} };
|
||||
`export const run = async function* () {
|
||||
yield { role: "x", contentHash: "STUBHASH00000000000000001", meta: {}, refs: [] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
}
|
||||
`,
|
||||
@@ -139,9 +153,13 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
schema: { type: "object", properties: { greeting: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
export const run = async function* (input) {
|
||||
yield { role: "greeter", content: input.prompt, meta: { greeting: "hi" } };
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
`,
|
||||
@@ -179,8 +197,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -208,8 +228,10 @@ export const run = async function* (input) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -239,8 +261,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -260,13 +284,17 @@ export const run = async function* (input) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v1", meta: {} };
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v2", meta: {} };
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
`;
|
||||
@@ -298,13 +326,17 @@ export const run = async function* (input) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v1", meta: {} };
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v2", meta: {} };
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
`;
|
||||
@@ -346,8 +378,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -357,8 +391,10 @@ export const run = async function* (input) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "y", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
`,
|
||||
@@ -371,14 +407,49 @@ export const run = async function* (input) {
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
|
||||
const raw = "phase doc";
|
||||
const stored = casStoredForm(raw);
|
||||
const put = await cmdCasPut(storageRoot, raw);
|
||||
expect(put.ok).toBe(true);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = put.value;
|
||||
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||
expect(await readFile(blobPath, "utf8")).toBe(stored);
|
||||
|
||||
const got = await cmdCasGet(storageRoot, hash);
|
||||
expect(got.ok).toBe(true);
|
||||
if (!got.ok) {
|
||||
return;
|
||||
}
|
||||
expect(got.value).toBe(stored);
|
||||
|
||||
const listed = await cmdCasList(storageRoot);
|
||||
expect(listed.ok).toBe(true);
|
||||
if (!listed.ok) {
|
||||
return;
|
||||
}
|
||||
expect(listed.value).toContain(hash);
|
||||
|
||||
const removed = await cmdCasRm(storageRoot, hash);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
const missing = await cmdCasGet(storageRoot, hash);
|
||||
expect(missing.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("rollback rejects missing bundle file for target hash", async () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -392,8 +463,10 @@ export const run = async function* (input) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "y", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
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, null);
|
||||
return {
|
||||
fetch: (path: string, init?: RequestInit) =>
|
||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("serve /healthz", () => {
|
||||
test("returns ok", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/healthz");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/workflows", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/workflows");
|
||||
// Registry file won't exist, should return error
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/threads", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { threads: unknown[] };
|
||||
expect(body.threads).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns 404 for missing thread", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads/nonexistent-id");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/threads/running", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads/running");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { threads: unknown[] };
|
||||
expect(body.threads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/cas", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { hashes: unknown[] };
|
||||
expect(body.hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns 404 for missing hash", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas/nonexistent-hash");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
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()}`;
|
||||
|
||||
test("put then get", async () => {
|
||||
const { fetch } = buildApp(tmpDir);
|
||||
|
||||
const putRes = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "hello world" }),
|
||||
});
|
||||
expect(putRes.status).toBe(201);
|
||||
const putBody = (await putRes.json()) as { hash: string };
|
||||
expect(typeof putBody.hash).toBe("string");
|
||||
|
||||
const getRes = await fetch(`/api/cas/${putBody.hash}`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as { content: string };
|
||||
expect(getBody.content).toBe(casStoredForm("hello world"));
|
||||
|
||||
// cleanup
|
||||
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
|
||||
expect(delRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVECMPLT01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963400000}
|
||||
{"role":"planner","contentHash":"FF7YQ5W3S2EV6","meta":{"phase":"plan","flags":[1,2]},"refs":[],"timestamp":1714963201000}
|
||||
{"role":"coder","contentHash":"EN34XX1W4WAFJ","meta":{},"refs":[],"timestamp":1714963202000}
|
||||
{"returnCode":0,"summary":"fixture completed"}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"tag":"DEBUGTAG1","content":"bundle loaded","timestamp":1714963400050}
|
||||
{"tag":"DEBUGTAG2","content":"multi\nline","timestamp":1714963400500}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVEINFLY01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963200000}
|
||||
{"role":"planner","contentHash":"P6M9FHE1GSBN0","meta":{"x":1},"refs":[],"timestamp":1714963201000}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"name":"demo-live-old","hash":"C9NMV6V2TQT81","threadId":"01LIVEOLDER01DDDDDDDDDDDDG","parameters":{"prompt":"old","options":{"maxRounds":5,"depth":0}},"timestamp":1714963000000}
|
||||
{"returnCode":0,"summary":"older thread"}
|
||||
@@ -1,62 +1,51 @@
|
||||
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 { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdFork } from "../src/cmd-fork.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
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 = `export const descriptor = {
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
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) {
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
yield { role: "planner", content: "p1", meta: { k: "planner" } };
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
yield { role: "coder", content: "c1", meta: { k: "coder" } };
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
yield {
|
||||
role: "reviewer",
|
||||
content: "rev-" + String(input.steps.length),
|
||||
meta: { k: "reviewer" },
|
||||
};
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, 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))) {
|
||||
@@ -66,6 +55,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;
|
||||
@@ -74,6 +98,7 @@ describe("cli fork", () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -98,16 +123,18 @@ describe("cli fork", () => {
|
||||
}
|
||||
const hash = added.value.hash;
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
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, 4);
|
||||
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);
|
||||
@@ -115,24 +142,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, 4);
|
||||
await waitUntilThreadCompletes(storageRoot, newId);
|
||||
|
||||
const text = await readFile(newData, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(4);
|
||||
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 last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
expect(last.content).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 () => {
|
||||
@@ -148,16 +169,14 @@ describe("cli fork", () => {
|
||||
}
|
||||
const hash = added.value.hash;
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
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, 4);
|
||||
await waitUntilRunningAbsent(join(storageRoot, "logs", hash, `${sourceId}.running`));
|
||||
await waitUntilThreadCompletes(storageRoot, sourceId);
|
||||
|
||||
const forked = await cmdFork(storageRoot, sourceId, null);
|
||||
expect(forked.ok).toBe(true);
|
||||
@@ -165,25 +184,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, 4);
|
||||
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(4);
|
||||
|
||||
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(replayCoder.role).toBe("coder");
|
||||
expect(replayCoder.content).toBe("c1");
|
||||
|
||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
expect(last.content).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 () => {
|
||||
@@ -198,16 +209,16 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
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, 4);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
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, 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));
|
||||
|
||||
describe("gc cli and garbageCollectCas", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-gc-"));
|
||||
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("garbageCollectCas keeps CAS entries reachable from threads.json roots", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01AAA1111111111111111111";
|
||||
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const orphanHash = await cas.put("orphan-blob");
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const gc = await garbageCollectCas(storageRoot);
|
||||
expect(gc.ok).toBe(true);
|
||||
if (!gc.ok) {
|
||||
return;
|
||||
}
|
||||
expect(gc.value.scannedThreads).toBe(2);
|
||||
expect(gc.value.deletedEntries).toBe(1);
|
||||
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("garbageCollectCas deletes orphaned CAS when no threads reference them", async () => {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const orphanHash = await cas.put("lonely");
|
||||
|
||||
const gc = await garbageCollectCas(storageRoot);
|
||||
expect(gc.ok).toBe(true);
|
||||
if (!gc.ok) {
|
||||
return;
|
||||
}
|
||||
expect(gc.value.scannedThreads).toBe(0);
|
||||
expect(gc.value.activeRefs).toBe(0);
|
||||
expect(gc.value.deletedEntries).toBe(1);
|
||||
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
||||
});
|
||||
|
||||
test("cli gc prints stats", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01BBB2222222222222222222";
|
||||
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
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 upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const proc = spawnSync(process.execPath, [cliEntryPath, "cas", "gc"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(proc.status).toBe(0);
|
||||
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 bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{
|
||||
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");
|
||||
const orphanPath = join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`);
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
expect(await pathExists(orphanPath)).toBe(false);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${promptHash}.txt`))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "../src/skill.js";
|
||||
|
||||
const STORAGE_ROOT = "/tmp/help-test-storage";
|
||||
|
||||
describe("runCli usage", () => {
|
||||
test("no args prints usage and returns 1", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, []);
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill command", () => {
|
||||
test("skill (no topic) lists topics and returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill cli returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "cli"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill develop returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "develop"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill author returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "author"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill unknown returns 1", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "unknown"]);
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--help flag on groups", () => {
|
||||
test("workflow --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["workflow", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("thread --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["thread", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("cas --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["cas", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("init --help returns 0", async () => {
|
||||
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", () => {
|
||||
test("returns all topics", () => {
|
||||
const topics = getSkillTopics();
|
||||
const names = topics.map((t) => t.name);
|
||||
expect(names).toContain("cli");
|
||||
expect(names).toContain("develop");
|
||||
expect(names).toContain("author");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillIndex", () => {
|
||||
test("lists all topics", () => {
|
||||
const idx = formatSkillIndex();
|
||||
expect(idx).toContain("# uncaged-workflow skill");
|
||||
expect(idx).not.toContain("# uncaged-workflow help --skill");
|
||||
expect(idx).toContain("cli");
|
||||
expect(idx).toContain("develop");
|
||||
expect(idx).toContain("author");
|
||||
expect(idx).toContain("skill <topic>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCliUsage", () => {
|
||||
test("has tagline, grouped sections, help hint, and env vars", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u.startsWith("uncaged-workflow — workflow engine CLI")).toBe(true);
|
||||
expect(u).toContain("Workflow registry:");
|
||||
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]");
|
||||
expect(u).toContain("Agent-consumable docs");
|
||||
expect(u).toContain("Use <command> --help for subcommand details.");
|
||||
expect(u).toContain("Environment variables:");
|
||||
expect(u).toContain("WORKFLOW_STORAGE_ROOT");
|
||||
expect(u).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
|
||||
});
|
||||
|
||||
test("lists commands from registry with descriptions", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u).toContain("workflow add");
|
||||
expect(u).toContain("Register a workflow bundle in the registry");
|
||||
expect(u).toContain("thread run");
|
||||
expect(u).toContain("Start a new thread executing a workflow");
|
||||
expect(u).toContain("cas gc");
|
||||
expect(u).toContain("Garbage-collect unreferenced CAS entries");
|
||||
});
|
||||
});
|
||||
|
||||
const cliSkillDoc = formatSkillTopic("cli");
|
||||
if (cliSkillDoc === null) {
|
||||
throw new Error("BUG: cli skill topic missing");
|
||||
}
|
||||
|
||||
describe("formatSkillTopic('cli')", () => {
|
||||
const doc = cliSkillDoc;
|
||||
|
||||
test("contains title", () => {
|
||||
expect(doc).toContain("# uncaged-workflow CLI Reference");
|
||||
});
|
||||
|
||||
test("contains all command group headers", () => {
|
||||
expect(doc).toContain("### workflow");
|
||||
expect(doc).toContain("### thread");
|
||||
expect(doc).toContain("### cas");
|
||||
expect(doc).toContain("### init");
|
||||
expect(doc).toContain("### setup");
|
||||
expect(doc).toContain("### Top-level shortcuts");
|
||||
});
|
||||
|
||||
test("contains core concepts", () => {
|
||||
expect(doc).toContain("## Core Concepts");
|
||||
expect(doc).toContain("Workflow");
|
||||
expect(doc).toContain("Bundle");
|
||||
expect(doc).toContain("Thread");
|
||||
expect(doc).toContain("CAS");
|
||||
expect(doc).toContain("Registry");
|
||||
});
|
||||
|
||||
test("mentions all workflow subcommands", () => {
|
||||
for (const sub of ["add", "list", "show", "rm", "history", "rollback"]) {
|
||||
expect(doc).toContain(`workflow ${sub}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("mentions all thread subcommands", () => {
|
||||
for (const sub of [
|
||||
"run",
|
||||
"list",
|
||||
"show",
|
||||
"rm",
|
||||
"fork",
|
||||
"ps",
|
||||
"kill",
|
||||
"live",
|
||||
"pause",
|
||||
"resume",
|
||||
]) {
|
||||
expect(doc).toContain(`thread ${sub}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("mentions all cas subcommands", () => {
|
||||
for (const sub of ["get", "put", "list", "rm", "gc"]) {
|
||||
expect(doc).toContain(`cas ${sub}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("contains exit codes section", () => {
|
||||
expect(doc).toContain("## Exit Codes");
|
||||
});
|
||||
|
||||
test("contains environment variables section", () => {
|
||||
expect(doc).toContain("## Environment Variables");
|
||||
expect(doc).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
|
||||
});
|
||||
|
||||
test("contains typical workflow section", () => {
|
||||
expect(doc).toContain("## Typical Workflow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillTopic('develop')", () => {
|
||||
const doc = formatSkillTopic("develop");
|
||||
|
||||
test("returns non-null", () => {
|
||||
expect(doc).not.toBeNull();
|
||||
});
|
||||
|
||||
test("contains thread ID info", () => {
|
||||
expect(doc).toContain("Thread ID");
|
||||
expect(doc).toContain("Crockford Base32");
|
||||
});
|
||||
|
||||
test("contains CAS commands", () => {
|
||||
expect(doc).toContain("cas put");
|
||||
expect(doc).toContain("cas get");
|
||||
});
|
||||
|
||||
test("contains meta output section", () => {
|
||||
expect(doc).toContain("Meta Output");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillTopic('author')", () => {
|
||||
const doc = formatSkillTopic("author");
|
||||
|
||||
test("returns non-null", () => {
|
||||
expect(doc).not.toBeNull();
|
||||
});
|
||||
|
||||
test("contains bundle structure", () => {
|
||||
expect(doc).toContain("Bundle Structure");
|
||||
expect(doc).toContain(".esm.js");
|
||||
});
|
||||
|
||||
test("contains descriptor info", () => {
|
||||
expect(doc).toContain("WorkflowDescriptor");
|
||||
});
|
||||
|
||||
test("contains role definition", () => {
|
||||
expect(doc).toContain("Role Definition");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillTopic unknown", () => {
|
||||
test("returns null for unknown topic", () => {
|
||||
expect(formatSkillTopic("nonexistent")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdInitTemplate, cmdInitWorkspace } from "../src/commands/init/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
describe("init template", () => {
|
||||
let parent: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
parent = join(
|
||||
tmpdir(),
|
||||
`wf-init-template-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
await mkdir(parent, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("creates templates/<name> with expected files", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "my-workflows");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
|
||||
const created = await cmdInitTemplate(root, "review-pr");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tdir = join(root, "templates", "review-pr");
|
||||
expect(created.value.templatePath).toBe(tdir);
|
||||
expect(await pathExists(join(tdir, "package.json"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "tsconfig.json"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "src", "roles.ts"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "src", "moderator.ts"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "src", "index.ts"))).toBe(true);
|
||||
|
||||
const pkg = JSON.parse(await readFile(join(tdir, "package.json"), "utf8")) as {
|
||||
name: string;
|
||||
type: string;
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(pkg.type).toBe("module");
|
||||
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(pkg.dependencies.zod).toBeDefined();
|
||||
expect(pkg.name).toContain("review-pr");
|
||||
|
||||
const idx = await readFile(join(tdir, "src", "index.ts"), "utf8");
|
||||
expect(idx).toContain("WorkflowDefinition");
|
||||
|
||||
const roles = await readFile(join(tdir, "src", "roles.ts"), "utf8");
|
||||
expect(roles).not.toContain("interface ");
|
||||
expect(roles).not.toContain("?:");
|
||||
expect(roles).not.toContain("export default");
|
||||
|
||||
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 () => {
|
||||
const ws = await cmdInitWorkspace(parent, "ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
const nested = join(root, "a", "b");
|
||||
await mkdir(nested, { recursive: true });
|
||||
|
||||
const created = await cmdInitTemplate(nested, "nested-tpl");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
expect(await pathExists(join(root, "templates", "nested-tpl", "src", "index.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
test("errors when not inside a workflow workspace", async () => {
|
||||
const orphan = join(parent, "nowhere");
|
||||
await mkdir(orphan, { recursive: true });
|
||||
const r = await cmdInitTemplate(orphan, "x");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("templates/*");
|
||||
}
|
||||
});
|
||||
|
||||
test("errors when template directory already exists", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
|
||||
const first = await cmdInitTemplate(root, "dup");
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
const second = await cmdInitTemplate(root, "dup");
|
||||
expect(second.ok).toBe(false);
|
||||
if (!second.ok) {
|
||||
expect(second.error).toContain("already exists");
|
||||
}
|
||||
});
|
||||
|
||||
test("errors on invalid template name", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const bad = await cmdInitTemplate(ws.value.rootPath, "a/b");
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
|
||||
test.serial("runCli init template uses cwd and succeeds in workspace", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "cli-ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
const prev = process.cwd();
|
||||
try {
|
||||
process.chdir(root);
|
||||
const code = await runCli(join(parent, "_storage"), ["init", "template", "from-cli"]);
|
||||
expect(code).toBe(0);
|
||||
expect(await pathExists(join(root, "templates", "from-cli", "package.json"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdInitWorkspace } from "../src/commands/init/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
describe("init workspace", () => {
|
||||
let parent: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
parent = join(tmpdir(), `wf-init-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(parent, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("creates expected files and directories", async () => {
|
||||
const created = await cmdInitWorkspace(parent, "my-workflows");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = created.value.rootPath;
|
||||
expect(await pathExists(join(root, "package.json"))).toBe(true);
|
||||
expect(await pathExists(join(root, "biome.json"))).toBe(true);
|
||||
expect(await pathExists(join(root, "tsconfig.json"))).toBe(true);
|
||||
expect(await pathExists(join(root, "AGENTS.md"))).toBe(true);
|
||||
expect(await pathExists(join(root, "README.md"))).toBe(true);
|
||||
expect(await pathExists(join(root, "templates"))).toBe(true);
|
||||
expect(await pathExists(join(root, "templates", ".gitkeep"))).toBe(true);
|
||||
expect(await pathExists(join(root, "workflows", "package.json"))).toBe(true);
|
||||
|
||||
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-runtime"]).toBeDefined();
|
||||
expect(wfPkg.dependencies.zod).toBeDefined();
|
||||
|
||||
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
|
||||
compilerOptions: { strict: boolean; module: string; target: string };
|
||||
};
|
||||
expect(tsconfig.compilerOptions.strict).toBe(true);
|
||||
expect(tsconfig.compilerOptions.module).toBe("ESNext");
|
||||
expect(tsconfig.compilerOptions.target).toBe("ESNext");
|
||||
});
|
||||
|
||||
test("AGENTS.md contains coding agent guide sections and terms", async () => {
|
||||
const created = await cmdInitWorkspace(parent, "my-workflows");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentsPath = join(created.value.rootPath, "AGENTS.md");
|
||||
const body = await readFile(agentsPath, "utf8");
|
||||
|
||||
for (const section of [
|
||||
"项目结构",
|
||||
"核心概念",
|
||||
"开发流程",
|
||||
"编码规范",
|
||||
"Template",
|
||||
"Build",
|
||||
"常见陷阱",
|
||||
]) {
|
||||
expect(body).toContain(section);
|
||||
}
|
||||
|
||||
for (const term of [
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"ModeratorTable",
|
||||
"AdapterFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
]) {
|
||||
expect(body).toContain(term);
|
||||
}
|
||||
|
||||
expect(body).toMatch(/type[\s\S]*interface/i);
|
||||
expect(body).toMatch(/function[\s\S]*class/i);
|
||||
expect(body).toContain("Crockford Base32");
|
||||
expect(body).toMatch(/no[\s\S]*default export/i);
|
||||
expect(body).toMatch(/no[\s\S]*console/i);
|
||||
expect(body).toMatch(/no[\s\S]*dynamic import/i);
|
||||
|
||||
expect(body).toContain("bun run check");
|
||||
expect(body).toContain("bun test");
|
||||
expect(body).toContain("uncaged-workflow");
|
||||
expect(body).toContain("bun build");
|
||||
expect(body).toContain("CLAUDE.md");
|
||||
expect(body).toContain("docs/architecture.md");
|
||||
});
|
||||
|
||||
test("errors when directory already exists", async () => {
|
||||
const first = await cmdInitWorkspace(parent, "dup");
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
const second = await cmdInitWorkspace(parent, "dup");
|
||||
expect(second.ok).toBe(false);
|
||||
if (!second.ok) {
|
||||
expect(second.error).toContain("already exists");
|
||||
}
|
||||
});
|
||||
|
||||
test("errors on invalid workspace name", async () => {
|
||||
const dots = await cmdInitWorkspace(parent, "..");
|
||||
expect(dots.ok).toBe(false);
|
||||
|
||||
const empty = await cmdInitWorkspace(parent, "");
|
||||
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>");
|
||||
expect(u).toContain("init template <name>");
|
||||
expect(u).toContain("Development:");
|
||||
});
|
||||
|
||||
test("runCli rejects unknown init subcommand", async () => {
|
||||
const code = await runCli(join(parent, "_storage"), ["init", "bogus", "name"]);
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
|
||||
test.serial("runCli init workspace uses cwd", async () => {
|
||||
const prev = process.cwd();
|
||||
try {
|
||||
process.chdir(parent);
|
||||
const code = await runCli(join(parent, "_storage"), ["init", "workspace", "from-cli"]);
|
||||
expect(code).toBe(0);
|
||||
expect(await pathExists(join(parent, "from-cli", "workflows", "package.json"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
|
||||
|
||||
describe("resolveWorkflowStorageRoot", () => {
|
||||
let savedInternal: string | undefined;
|
||||
let savedUser: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedInternal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
savedUser = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedInternal === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = savedInternal;
|
||||
}
|
||||
if (savedUser === undefined) {
|
||||
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = savedUser;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns default when no env vars are set", () => {
|
||||
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||
});
|
||||
|
||||
test("WORKFLOW_STORAGE_ROOT overrides default", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/custom-storage";
|
||||
expect(resolveWorkflowStorageRoot()).toBe("/tmp/custom-storage");
|
||||
});
|
||||
|
||||
test("UNCAGED_WORKFLOW_STORAGE_ROOT takes priority over WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-path";
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/internal-path";
|
||||
expect(resolveWorkflowStorageRoot()).toBe("/tmp/internal-path");
|
||||
});
|
||||
|
||||
test("ignores empty WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "";
|
||||
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||
});
|
||||
|
||||
test("ignores empty UNCAGED_WORKFLOW_STORAGE_ROOT and falls through to WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "";
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-fallback";
|
||||
expect(resolveWorkflowStorageRoot()).toBe("/tmp/user-fallback");
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,30 @@
|
||||
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 { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdKill } from "../src/cmd-kill.js";
|
||||
import { cmdPause } from "../src/cmd-pause.js";
|
||||
import { cmdPs } from "../src/cmd-ps.js";
|
||||
import { cmdResume } from "../src/cmd-resume.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
|
||||
import { cmdThreads } from "../src/cmd-threads.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
import { getBundleDir, readThreadsIndex } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdCasPut } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdKill,
|
||||
cmdPause,
|
||||
cmdPs,
|
||||
cmdResume,
|
||||
cmdRun,
|
||||
cmdThreadRemove,
|
||||
cmdThreadShow,
|
||||
cmdThreads,
|
||||
} 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-cas";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
description: "thread-cli",
|
||||
@@ -25,22 +36,31 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
only: { description: "only", schema: {} },
|
||||
noop: { description: "noop", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
@@ -48,59 +68,57 @@ export const run = async function* (input) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
yield { role: "first", content: "f", meta: {} };
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
yield { role: "second", content: "s", meta: {} };
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
yield { role: "only", content: "x", meta: {} };
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "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));
|
||||
@@ -115,6 +133,7 @@ describe("cli thread commands", () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -138,7 +157,7 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
@@ -161,6 +180,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) {
|
||||
@@ -168,22 +190,81 @@ 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 () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(bundlePath, fastBundleSource, "utf8");
|
||||
|
||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||
expect(added.ok).toBe(true);
|
||||
if (!added.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
|
||||
let threads = await cmdThreads(storageRoot, []);
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 50 && threads.ok && !threads.value.some((l) => l.includes(threadId));
|
||||
attempt++
|
||||
) {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
threads = await cmdThreads(storageRoot, []);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = put.value;
|
||||
const casBlob = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
const stillThere = await readTextFileIfExists(casBlob);
|
||||
expect(stillThere).toBeNull();
|
||||
});
|
||||
|
||||
test("cli entrypoint dispatches threads / ps (spawn)", () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const threads = spawnSync(process.execPath, [cliEntryPath, "threads"], {
|
||||
const threads = spawnSync(process.execPath, [cliEntryPath, "thread", "list"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(threads.status).toBe(0);
|
||||
|
||||
const ps = spawnSync(process.execPath, [cliEntryPath, "ps"], { env, encoding: "utf8" });
|
||||
const ps = spawnSync(process.execPath, [cliEntryPath, "thread", "ps"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(ps.status).toBe(0);
|
||||
});
|
||||
|
||||
@@ -199,7 +280,7 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
@@ -229,37 +310,38 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(2);
|
||||
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));
|
||||
@@ -268,31 +350,40 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
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, 3, 120);
|
||||
expect(await countDataJsonlLines(dataPath)).toBe(3);
|
||||
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);
|
||||
});
|
||||
@@ -309,15 +400,14 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -338,7 +428,7 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Minimal valid global config so {@link executeThread} can resolve the extract scene (CLI integration tests). */
|
||||
export const TEST_WORKFLOW_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/m
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
export async function ensureTestWorkflowRegistryConfig(storageRoot: string): Promise<void> {
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), TEST_WORKFLOW_REGISTRY_YAML, "utf8");
|
||||
}
|
||||
@@ -1,16 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@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": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+51
@@ -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,16 +1,9 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
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";
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
import { pathExists } from "./fs-utils.js";
|
||||
|
||||
export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string };
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function shouldUseColor(): boolean {
|
||||
return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
||||
}
|
||||
|
||||
export function highlightLiveRole(name: string): string {
|
||||
if (!shouldUseColor()) {
|
||||
return name;
|
||||
}
|
||||
return `\x1b[1m\x1b[36m${name}\x1b[0m`;
|
||||
}
|
||||
|
||||
export function dimGreyLine(line: string): string {
|
||||
if (!shouldUseColor()) {
|
||||
return line;
|
||||
}
|
||||
return `\x1b[2m\x1b[90m${line}\x1b[0m`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||
|
||||
export type CommandEntry = {
|
||||
handler: DispatchFn;
|
||||
args: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type CommandGroup = {
|
||||
name: string;
|
||||
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
|
||||
};
|
||||
|
||||
export type DispatchGroupFn = (
|
||||
tableName: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
) => Promise<number> | null;
|
||||
@@ -1,316 +1,96 @@
|
||||
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
|
||||
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
|
||||
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
|
||||
import { cmdHistory } from "./cmd-history.js";
|
||||
import { cmdKill } from "./cmd-kill.js";
|
||||
import { cmdList, formatListLines } from "./cmd-list.js";
|
||||
import { cmdPause } from "./cmd-pause.js";
|
||||
import { cmdPs } from "./cmd-ps.js";
|
||||
import { cmdRemove } from "./cmd-remove.js";
|
||||
import { cmdResume } from "./cmd-resume.js";
|
||||
import { cmdRollback } from "./cmd-rollback.js";
|
||||
import { cmdRun } from "./cmd-run.js";
|
||||
import { cmdShow, formatShowYaml } from "./cmd-show.js";
|
||||
import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js";
|
||||
import { cmdThreads } from "./cmd-threads.js";
|
||||
import { parseRunArgv } from "./run-argv.js";
|
||||
import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
|
||||
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 { 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";
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage:",
|
||||
" uncaged-workflow add <name> <file.esm.js> [--types <path>]",
|
||||
" uncaged-workflow list",
|
||||
" uncaged-workflow show <name>",
|
||||
" uncaged-workflow remove <name>",
|
||||
" uncaged-workflow run <name> [--prompt <text>] [--dry-run] [--max-rounds N]",
|
||||
" uncaged-workflow ps",
|
||||
" uncaged-workflow kill <thread-id>",
|
||||
" uncaged-workflow history <name>",
|
||||
" uncaged-workflow rollback <name> [hash]",
|
||||
" uncaged-workflow pause <thread-id>",
|
||||
" uncaged-workflow resume <thread-id>",
|
||||
" uncaged-workflow threads [name]",
|
||||
" uncaged-workflow thread <id>",
|
||||
" uncaged-workflow thread rm <id>",
|
||||
" uncaged-workflow fork <thread-id> [--from-role <role>]",
|
||||
].join("\n");
|
||||
function dispatchGroup(
|
||||
tableName: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
): Promise<number> | null {
|
||||
const sub = argv[0];
|
||||
if (sub === undefined || sub === "--help" || sub === "-h") {
|
||||
const entries = Object.entries(table);
|
||||
const lines = [`${tableName} subcommands:\n`];
|
||||
for (const [name, e] of entries) {
|
||||
const args = e.args ? ` ${e.args}` : "";
|
||||
lines.push(` uncaged-workflow ${tableName} ${name}${args}`);
|
||||
lines.push(` ${e.description}\n`);
|
||||
}
|
||||
printCliLine(lines.join("\n"));
|
||||
return Promise.resolve(sub === undefined ? 1 : 0);
|
||||
}
|
||||
const entry = table[sub];
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
return entry.handler(storageRoot, argv.slice(1));
|
||||
}
|
||||
|
||||
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseAddArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
export function formatCliUsage(): string {
|
||||
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
|
||||
}
|
||||
|
||||
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
|
||||
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
||||
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
||||
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
||||
|
||||
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
|
||||
if (topic === undefined) {
|
||||
printCliLine(formatSkillIndex());
|
||||
return 0;
|
||||
}
|
||||
const doc = formatSkillTopic(topic);
|
||||
if (doc === null) {
|
||||
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdAdd(storageRoot, parsed.value);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const w of result.value.warnings) {
|
||||
printCliWarn(w);
|
||||
}
|
||||
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
|
||||
printCliLine(doc);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usage()}\n\nerror: list takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdList(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of formatListLines(result.value)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
return showSkillDocOrIndex(argv[0]);
|
||||
}
|
||||
|
||||
async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: show requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdShow(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatShowYaml(name, result.value));
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: remove requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdRemove(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed workflow "${name}" from registry`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseRunArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.dryRun,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usage()}\n\nerror: ps takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
for (const line of await cmdPs(storageRoot)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: kill requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`kill sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: history requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdHistory(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 2) {
|
||||
printCliError(`${usage()}\n\nerror: rollback requires <name> [hash]`);
|
||||
return 1;
|
||||
}
|
||||
const hashArg = argv[1];
|
||||
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`rolled back workflow "${name}"`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: pause requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`pause sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: resume requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`resume sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThreads(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = await cmdThreads(storageRoot, argv);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: thread requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: thread rm requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadRemove(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed thread ${id}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThreadBranch(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const sub = rest[0];
|
||||
if (sub === "rm") {
|
||||
return dispatchThreadRm(storageRoot, rest.slice(1));
|
||||
}
|
||||
return dispatchThread(storageRoot, rest);
|
||||
}
|
||||
|
||||
async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseForkArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||
|
||||
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
add: dispatchAdd,
|
||||
list: dispatchList,
|
||||
show: dispatchShow,
|
||||
remove: dispatchRemove,
|
||||
workflow: dispatchWorkflow,
|
||||
thread: dispatchThread,
|
||||
cas: dispatchCas,
|
||||
init: dispatchInit,
|
||||
setup: dispatchSetup,
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
ps: dispatchPs,
|
||||
kill: dispatchKill,
|
||||
history: dispatchHistory,
|
||||
rollback: dispatchRollback,
|
||||
pause: dispatchPause,
|
||||
resume: dispatchResume,
|
||||
threads: dispatchThreads,
|
||||
thread: dispatchThreadBranch,
|
||||
fork: dispatchFork,
|
||||
live: dispatchLive,
|
||||
connect: dispatchConnect,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length === 0) {
|
||||
printCliError(usage());
|
||||
printCliLine(formatCliUsage());
|
||||
return 1;
|
||||
}
|
||||
const command = argv[0];
|
||||
if (command === undefined) {
|
||||
printCliError(usage());
|
||||
printCliLine(formatCliUsage());
|
||||
return 1;
|
||||
}
|
||||
const rest = argv.slice(1);
|
||||
|
||||
const dispatch = COMMAND_TABLE[command];
|
||||
if (dispatch === undefined) {
|
||||
printCliError(`${usage()}\n\nerror: unknown command ${command}`);
|
||||
return 1;
|
||||
if (dispatch !== undefined) {
|
||||
return dispatch(storageRoot, rest);
|
||||
}
|
||||
return dispatch(storageRoot, rest);
|
||||
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { CommandGroup } from "./cli-command-types.js";
|
||||
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
|
||||
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
|
||||
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { 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 [
|
||||
{
|
||||
name: "workflow",
|
||||
commands: Object.entries(WORKFLOW_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "thread",
|
||||
commands: Object.entries(THREAD_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "cas",
|
||||
commands: Object.entries(CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "init",
|
||||
commands: Object.entries(INIT_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
commands: [...SETUP_USAGE_COMMANDS],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
setCommandGroupsForUsage(getCommandRegistry());
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CommandGroup } from "./cli-command-types.js";
|
||||
|
||||
let commandGroupsForUsage: ReadonlyArray<CommandGroup> | null = null;
|
||||
|
||||
export function setCommandGroupsForUsage(groups: ReadonlyArray<CommandGroup>): void {
|
||||
commandGroupsForUsage = groups;
|
||||
}
|
||||
|
||||
export function getCommandGroupsForUsage(): ReadonlyArray<CommandGroup> {
|
||||
if (commandGroupsForUsage === null) {
|
||||
throw new Error("BUG: command groups for usage not initialized");
|
||||
}
|
||||
return commandGroupsForUsage;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { CommandGroup } from "./cli-command-types.js";
|
||||
|
||||
/** Keep aligned with `getSkillTopics()` in skill.ts (names only, for error usage lines). */
|
||||
export const USAGE_SKILL_TOPIC_ROWS: ReadonlyArray<{ name: string }> = [
|
||||
{ name: "cli" },
|
||||
{ name: "develop" },
|
||||
{ name: "author" },
|
||||
];
|
||||
|
||||
const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
workflow: "Workflow registry:",
|
||||
thread: "Thread execution:",
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
setup: "Configuration:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
rows: ReadonlyArray<{ prefix: string; description: string }>,
|
||||
): string[] {
|
||||
const maxPrefix = rows.reduce((max, row) => Math.max(max, row.prefix.length), 0);
|
||||
const gap = 2;
|
||||
return rows.map((row) => {
|
||||
const pad = " ".repeat(maxPrefix - row.prefix.length + gap);
|
||||
return ` ${row.prefix}${pad}${row.description}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCliUsage(
|
||||
groups: ReadonlyArray<CommandGroup>,
|
||||
skillTopics: ReadonlyArray<{ name: string }>,
|
||||
): string {
|
||||
const lines: string[] = ["uncaged-workflow — workflow engine CLI", ""];
|
||||
|
||||
for (const group of groups) {
|
||||
const sectionTitle = USAGE_SECTION_BY_GROUP[group.name];
|
||||
if (sectionTitle === undefined) {
|
||||
throw new Error(`BUG: missing usage section title for group "${group.name}"`);
|
||||
}
|
||||
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}${namePart}${args}`,
|
||||
description: cmd.description,
|
||||
};
|
||||
});
|
||||
lines.push(...formatUsageCommandLines(rows));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("Shortcuts:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{ prefix: "run <name> [...]", description: "→ thread run" },
|
||||
{ prefix: "live <id> [...]", description: "→ thread live" },
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Gateway:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "connect [--name NAME] [--gateway URL]",
|
||||
description: "Connect to workflow gateway via WebSocket",
|
||||
},
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Reference:");
|
||||
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "skill [topic]",
|
||||
description: `Agent-consumable docs (${skillTopicNames})`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("Use <command> --help for subcommand details.");
|
||||
lines.push("");
|
||||
lines.push("Environment variables:");
|
||||
lines.push(
|
||||
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
|
||||
);
|
||||
lines.push(
|
||||
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
Regular → Executable
@@ -1,91 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
||||
|
||||
export function parseForkArgv(
|
||||
argv: string[],
|
||||
): Result<{ threadId: string; fromRole: string | null }, string> {
|
||||
if (argv.length === 0) {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
let fromRole: string | null = null;
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--from-role") {
|
||||
const r = argv[i + 1];
|
||||
if (r === undefined || r === "") {
|
||||
return err("--from-role requires a role name");
|
||||
}
|
||||
fromRole = r;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
return err(`unexpected argument: ${a}`);
|
||||
}
|
||||
return ok({ threadId, fromRole });
|
||||
}
|
||||
|
||||
export async function cmdFork(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
fromRole: string | null,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return err(`thread data missing: ${threadId}`);
|
||||
}
|
||||
|
||||
const plan = buildForkPlan(text, fromRole);
|
||||
if (!plan.ok) {
|
||||
return plan;
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${plan.value.hash}.esm.js`);
|
||||
if (!(await pathExists(bundlePath))) {
|
||||
return err(`bundle file missing for thread hash ${plan.value.hash}`);
|
||||
}
|
||||
|
||||
const worker = await ensureWorkerForHash(storageRoot, plan.value.hash, bundlePath);
|
||||
if (!worker.ok) {
|
||||
return worker;
|
||||
}
|
||||
|
||||
const newThreadId = generateUlid(Date.now());
|
||||
const stepsOnWire = plan.value.historicalSteps.map((s) => ({
|
||||
role: s.role,
|
||||
content: s.content,
|
||||
meta: s.meta,
|
||||
timestamp: s.timestamp,
|
||||
}));
|
||||
|
||||
const sent = await sendWorkerTcpCommand(
|
||||
worker.value.port,
|
||||
{
|
||||
type: "run",
|
||||
threadId: newThreadId,
|
||||
workflowName: plan.value.workflowName,
|
||||
prompt: plan.value.prompt,
|
||||
options: plan.value.runOptions,
|
||||
steps: stepsOnWire,
|
||||
forkSourceThreadId: plan.value.sourceThreadId,
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
if (!sent.ok) {
|
||||
return sent;
|
||||
}
|
||||
|
||||
return ok({ threadId: newThreadId });
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import {
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
type WorkerCtl,
|
||||
} from "./worker-spawn.js";
|
||||
|
||||
export async function cmdKill(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctl.port,
|
||||
{ type: "kill", threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import {
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
type WorkerCtl,
|
||||
} from "./worker-spawn.js";
|
||||
|
||||
export async function cmdPause(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctl.port,
|
||||
{ type: "pause", threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import {
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
type WorkerCtl,
|
||||
} from "./worker-spawn.js";
|
||||
|
||||
export async function cmdResume(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctl.port,
|
||||
{ type: "resume", threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return err(`thread data missing: ${threadId}`);
|
||||
}
|
||||
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
|
||||
}
|
||||
|
||||
export async function cmdThreadRemove(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
const dir = dirname(dataPath);
|
||||
const infoPath = join(dir, `${threadId}.info.jsonl`);
|
||||
const runningPath = join(dir, `${threadId}.running`);
|
||||
|
||||
await unlink(dataPath);
|
||||
await unlink(infoPath).catch(() => {});
|
||||
await unlink(runningPath).catch(() => {});
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { cmdGc } from "./gc.js";
|
||||
import { cmdCasGet } from "./get.js";
|
||||
import { cmdCasList } from "./list.js";
|
||||
import { cmdCasPut } from "./put.js";
|
||||
import { cmdCasRm } from "./rm.js";
|
||||
import type { CasDispatchDeps } from "./types.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: gc takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdGc(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
const stats = result.value;
|
||||
printCliLine(
|
||||
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const hash = rest[0];
|
||||
if (hash === undefined || rest.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: cas get requires <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasGet(storageRoot, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const content = rest[0];
|
||||
if (content === undefined || rest.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: cas put requires <content>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasPut(storageRoot, content);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
|
||||
if (rest.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: cas list takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasList(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const hash of result.value) {
|
||||
printCliLine(hash);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const hash = rest[0];
|
||||
if (hash === undefined || rest.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: cas rm requires <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasRm(storageRoot, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed cas entry ${hash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
get: {
|
||||
handler: dispatchCasGet,
|
||||
args: "<hash>",
|
||||
description: "Retrieve content by hash from CAS",
|
||||
},
|
||||
put: {
|
||||
handler: dispatchCasPut,
|
||||
args: "<content>",
|
||||
description: "Store content in CAS, prints hash",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchCasList,
|
||||
args: "",
|
||||
description: "List all hashes in CAS",
|
||||
},
|
||||
rm: { handler: dispatchCasRm, args: "<hash>", description: "Remove a CAS entry by hash" },
|
||||
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
|
||||
};
|
||||
|
||||
export function createCasDispatcher(deps: CasDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${usageText()}\n\nerror: unknown cas subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
hash: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return err(`cas entry not found: ${hash}`);
|
||||
}
|
||||
return ok(content);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
CAS_SUBCOMMAND_TABLE,
|
||||
createCasDispatcher,
|
||||
dispatchCasGet,
|
||||
dispatchCasList,
|
||||
dispatchCasPut,
|
||||
dispatchCasRm,
|
||||
dispatchGc,
|
||||
} from "./dispatch.js";
|
||||
export { cmdGc } from "./gc.js";
|
||||
export { cmdCasGet } from "./get.js";
|
||||
export { cmdCasList } from "./list.js";
|
||||
export { cmdCasPut } from "./put.js";
|
||||
export { cmdCasRm } from "./rm.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
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));
|
||||
const hashes = await cas.list();
|
||||
return ok(hashes);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
content: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const hash = await cas.put(content);
|
||||
return ok(hash);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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));
|
||||
await cas.delete(hash);
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type CasDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -0,0 +1,57 @@
|
||||
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 hashes = await cas.list();
|
||||
return c.json({ hashes });
|
||||
});
|
||||
|
||||
app.get("/:hash", async (c) => {
|
||||
const content = await cas.get(c.req.param("hash"));
|
||||
if (content === null) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
return c.json({ hash: c.req.param("hash"), content });
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
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 hash = await cas.put(body.content);
|
||||
return c.json({ hash }, 201);
|
||||
});
|
||||
|
||||
app.delete("/:hash", async (c) => {
|
||||
const hash = c.req.param("hash");
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
await cas.delete(hash);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/gc", async (c) => {
|
||||
const result = await garbageCollectCas(storageRoot);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 500);
|
||||
}
|
||||
return c.json(result.value);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { WorkflowDescriptor } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
readWorkflowRegistry,
|
||||
validateWorkflowDescriptor,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { Hono } from "hono";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const names = listRegisteredWorkflowNames(reg.value);
|
||||
const workflows = names.map((name) => {
|
||||
const entry = reg.value.workflows[name];
|
||||
return {
|
||||
name,
|
||||
hash: entry?.hash ?? null,
|
||||
timestamp: entry?.timestamp ?? null,
|
||||
};
|
||||
});
|
||||
return c.json({ workflows });
|
||||
});
|
||||
|
||||
app.get("/:name", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const name = c.req.param("name");
|
||||
const entry = getRegisteredWorkflow(reg.value, name);
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
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) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const name = c.req.param("name");
|
||||
const entry = getRegisteredWorkflow(reg.value, name);
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, history: entry.history });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { cmdInitTemplate } from "./template.js";
|
||||
import type { InitDispatchDeps } from "./types.js";
|
||||
import { cmdInitWorkspace } from "./workspace.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: init workspace requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdInitWorkspace(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: init template requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdInitTemplate(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized template at ${result.value.templatePath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
workspace: {
|
||||
handler: dispatchInitWorkspace,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow workspace",
|
||||
},
|
||||
template: {
|
||||
handler: dispatchInitTemplate,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow template",
|
||||
},
|
||||
};
|
||||
|
||||
export function createInitDispatcher(deps: InitDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("init", INIT_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${usageText()}\n\nerror: unknown init subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
createInitDispatcher,
|
||||
dispatchInitTemplate,
|
||||
dispatchInitWorkspace,
|
||||
INIT_SUBCOMMAND_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdInitTemplate } from "./template.js";
|
||||
export type { CmdInitTemplateSuccess, CmdInitWorkspaceSuccess } from "./types.js";
|
||||
export { cmdInitWorkspace } from "./workspace.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
|
||||
import {
|
||||
templateIndexTs,
|
||||
templateModeratorTs,
|
||||
templatePackageJson,
|
||||
templateRolesTs,
|
||||
templateTsconfigJson,
|
||||
} from "./templates.js";
|
||||
import type { CmdInitTemplateSuccess } from "./types.js";
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
||||
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
||||
}
|
||||
|
||||
async function readPackageJsonWorkspaces(dir: string): Promise<unknown | null> {
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (!(await pathExists(pkgPath))) {
|
||||
return null;
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(pkgPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) {
|
||||
return null;
|
||||
}
|
||||
return (parsed as { workspaces: unknown }).workspaces;
|
||||
}
|
||||
|
||||
/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */
|
||||
async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<string, string>> {
|
||||
let dir = resolve(startDir);
|
||||
for (;;) {
|
||||
const workspaces = await readPackageJsonWorkspaces(dir);
|
||||
if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) {
|
||||
return ok(dir);
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) {
|
||||
return err(
|
||||
'not inside a workflow workspace (no package.json with workspaces containing "templates/*")',
|
||||
);
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdInitTemplate(
|
||||
startDir: string,
|
||||
templateName: string,
|
||||
): Promise<Result<CmdInitTemplateSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(templateName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
}
|
||||
|
||||
const rootResult = await findWorkflowWorkspaceRoot(startDir);
|
||||
if (!rootResult.ok) {
|
||||
return rootResult;
|
||||
}
|
||||
|
||||
const workspaceRoot = rootResult.value;
|
||||
const templateDir = join(workspaceRoot, "templates", templateName);
|
||||
if (await pathExists(templateDir)) {
|
||||
return err(`template already exists: ${templateDir}`);
|
||||
}
|
||||
|
||||
await mkdir(join(templateDir, "src"), { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"),
|
||||
writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ templatePath: templateDir });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export function templatePackageJson(templateName: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: `template-${templateName}`,
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function templateTsconfigJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
extends: "../../tsconfig.json",
|
||||
compilerOptions: {
|
||||
rootDir: "src",
|
||||
outDir: "dist",
|
||||
},
|
||||
include: ["src/**/*.ts"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function templateRolesTs(): string {
|
||||
return `import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||
"Minimal starter template: one greeter role, then END.";
|
||||
|
||||
export type HelloTemplateMeta = {
|
||||
greeter: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
const greeterMetaSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export function templateModeratorTs(): string {
|
||||
return `import { END, START, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "greeter" }],
|
||||
greeter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateTable } from "./moderator.js";
|
||||
import {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
|
||||
export {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
export { helloTemplateTable } from "./moderator.js";
|
||||
|
||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||
roles: {
|
||||
greeter: greeterRole,
|
||||
},
|
||||
table: helloTemplateTable,
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type CmdInitTemplateSuccess = {
|
||||
templatePath: string;
|
||||
};
|
||||
|
||||
export type CmdInitWorkspaceSuccess = {
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
export type InitDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
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> {
|
||||
if (name.length === 0) {
|
||||
return err("workspace name must not be empty");
|
||||
}
|
||||
if (name === "." || name === "..") {
|
||||
return err("invalid workspace name");
|
||||
}
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
return err("workspace name must not contain path separators");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||
|
||||
function rootPackageJson(workspaceName: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: workspaceName,
|
||||
private: true,
|
||||
type: "module",
|
||||
workspaces: ["templates/*", "workflows"],
|
||||
scripts: {
|
||||
bundle: "bun run scripts/bundle.ts",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function workflowsPackageJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: "workflows",
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function biomeJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
files: {
|
||||
// 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,
|
||||
},
|
||||
linter: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function tsconfigJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
strict: true,
|
||||
target: "ESNext",
|
||||
module: "ESNext",
|
||||
moduleResolution: "Bundler",
|
||||
skipLibCheck: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function agentsMd(): string {
|
||||
return `# AGENTS — Workflow 工作区开发指南
|
||||
|
||||
面向在本仓库中编写 workflow 的 coding agent。引擎层术语与架构细节与 **@uncaged/workflow** 上游文档一致,编写时可对照 \`CLAUDE.md\` 与 \`docs/architecture.md\`。
|
||||
|
||||
## 1. 项目结构(workspace / template / workflow instance)
|
||||
|
||||
| 层级 | 目录 / 产物 | 职责 |
|
||||
|------|----------------|------|
|
||||
| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
|
||||
| **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\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
|
||||
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
|
||||
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
|
||||
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
|
||||
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. 编码规范
|
||||
|
||||
与 **CLAUDE.md** 对齐,摘要如下:
|
||||
|
||||
- **Functional-first**:优先 \`function\` + \`type\`,避免面向对象业务模型。
|
||||
- **type 而非 interface**:类型别名一律用 \`type\`,不要使用 \`interface\`。
|
||||
- **显式可空**:不要用 \`?:\`;可空字段写成 \`T | null\`。
|
||||
- **function 而非 class**:不用 class(第三方库要求或 \`Error\` 子类除外)。
|
||||
- **Crockford Base32**:日志 tag、bundle hash、thread id 等标识约定(引擎侧);工作区内自定义日志若沿用引擎 logger,tag 为 8 字符 Crockford Base32,且每个调用点唯一。
|
||||
- **Named exports only**:不要使用 **default export**;workflow bundle 须 **export const run** 与 **export const descriptor**。
|
||||
- **No console.log**:库代码用结构化 logger;CLI 用户输出可按项目 Biome 规则例外标注。
|
||||
- **No dynamic import**:业务与 bundle 内禁止 \`import()\`;例外仅限「运行时路径由用户提供的 bundle 加载器」(引擎内部)。
|
||||
|
||||
## 5. Template 复用
|
||||
|
||||
- **已发布模板**:可通过 npm 依赖 \`@uncaged/workflow-template-*\` 等包,在 workflow 实例中 import 其 **WorkflowDefinition** 再绑定 Agent。
|
||||
- **本地模板**:放在本仓库 \`templates/<name>/\`,由 workspace 协议引用(如 \`"template-foo": "workspace:*"\` 或相对路径),便于同源修改与版本控制。
|
||||
|
||||
选择模板时保持 **definition 与 agent 绑定分离**:模板只描述「做什么、顺序如何」,实例决定「谁执行、如何抽取 meta」。
|
||||
|
||||
## 6. Build and Test
|
||||
|
||||
日常命令:
|
||||
|
||||
\`\`\`sh
|
||||
bun install
|
||||
bun run check # Biome:lint + format
|
||||
bun test
|
||||
bun build # 若包内配置了 build 脚本则用于产出 dist / bundle
|
||||
uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
\`\`\`
|
||||
|
||||
提交前至少运行 **bun run check** 与 **bun test**;registry 与本地运行流参见 README 与 CLI 文档。
|
||||
|
||||
## 7. 常见陷阱
|
||||
|
||||
- **No dynamic import**:bundle 须静态可分析;动态 \`import()\` 会破坏哈希与加载约束。
|
||||
- **No default export**:引擎只接受命名导出 \`run\` / \`descriptor\`。
|
||||
- **No console.log**:避免在可被 Biome \`noConsole\` 规则覆盖的代码路径直接使用 console。
|
||||
- **Single-file ESM bundle**:交付物是单一 \`.esm.js\`;静态 import 仅限 Node 内置(见 architecture 文档中的 Bundle Contract)。
|
||||
|
||||
---
|
||||
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
function bunfigToml(): string {
|
||||
return `[install.scopes]
|
||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||
`;
|
||||
}
|
||||
|
||||
function readmeMd(workspaceName: string): string {
|
||||
return `# ${workspaceName}
|
||||
|
||||
Local workflow development workspace (Bun monorepo).
|
||||
|
||||
## Layout
|
||||
|
||||
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
|
||||
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||
|
||||
## Commands
|
||||
|
||||
\`\`\`sh
|
||||
bun install
|
||||
bun run check # after you add scripts / Biome
|
||||
uncaged-workflow add <name> <bundle.esm.js>
|
||||
uncaged-workflow run <name>
|
||||
\`\`\`
|
||||
|
||||
Create this skeleton with:
|
||||
|
||||
\`\`\`sh
|
||||
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>> {
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
if (await pathExists(rootPath)) {
|
||||
return err(`directory already exists: ${rootPath}`);
|
||||
}
|
||||
|
||||
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(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(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;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import {
|
||||
readWorkerCtl,
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
} from "../../worker-spawn.js";
|
||||
|
||||
type ThreadControlAction = "kill" | "pause" | "resume";
|
||||
|
||||
async function cmdThreadControl(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
action: ThreadControlAction,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlResult = await readWorkerCtl(storageRoot, hashResult.value);
|
||||
if (!ctlResult.ok) {
|
||||
return ctlResult;
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctlResult.value.port,
|
||||
{ type: action, threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdKill(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
return cmdThreadControl(storageRoot, threadId, "kill");
|
||||
}
|
||||
|
||||
export async function cmdPause(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
return cmdThreadControl(storageRoot, threadId, "pause");
|
||||
}
|
||||
|
||||
export async function cmdResume(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
return cmdThreadControl(storageRoot, threadId, "resume");
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { parseLiveArgv } from "../../live-argv.js";
|
||||
import { parseRunArgv } from "../../run-argv.js";
|
||||
import { cmdKill, cmdPause, cmdResume } from "./control.js";
|
||||
import { cmdFork } from "./fork.js";
|
||||
import { parseForkArgv } from "./fork-argv.js";
|
||||
import { cmdThreads } from "./list.js";
|
||||
import { cmdLive } from "./live.js";
|
||||
import { cmdPs } from "./ps.js";
|
||||
import { cmdThreadRemove } from "./rm.js";
|
||||
import { cmdRun } from "./run.js";
|
||||
import { cmdThreadShow } from "./show.js";
|
||||
import type { ThreadDispatchDeps } from "./types.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseRunArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(storageRoot, parsed.value.name, parsed.value.prompt);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: ps takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
for (const line of await cmdPs(storageRoot)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: kill requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`kill sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseLiveArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
return cmdLive(storageRoot, parsed.value);
|
||||
}
|
||||
|
||||
export async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: pause requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`pause sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: resume requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`resume sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = await cmdThreads(storageRoot, argv);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: thread show requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: thread rm requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadRemove(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed thread ${id}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseForkArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
run: {
|
||||
handler: dispatchRun,
|
||||
args: "<name> [--prompt <text>]",
|
||||
description: "Start a new thread executing a workflow",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchThreadList,
|
||||
args: "[name]",
|
||||
description: "List threads, optionally filtered by workflow name",
|
||||
},
|
||||
show: { handler: dispatchThreadShow, args: "<id>", description: "Show thread details and state" },
|
||||
rm: { handler: dispatchThreadRm, args: "<id>", description: "Remove a thread" },
|
||||
fork: {
|
||||
handler: dispatchFork,
|
||||
args: "<thread-id> [--from-role <role>]",
|
||||
description: "Fork a thread, optionally from a specific role",
|
||||
},
|
||||
ps: { handler: dispatchPs, args: "", description: "List running threads" },
|
||||
kill: { handler: dispatchKill, args: "<thread-id>", description: "Kill a running thread" },
|
||||
live: {
|
||||
handler: dispatchLive,
|
||||
args: "<thread-id> | --latest [--debug] [--role <name>]",
|
||||
description: "Attach to a thread and stream output live",
|
||||
},
|
||||
pause: { handler: dispatchPause, args: "<thread-id>", description: "Pause a running thread" },
|
||||
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
|
||||
};
|
||||
|
||||
export function createThreadDispatcher(deps: ThreadDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${usageText()}\n\nerror: unknown thread subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { ParsedForkArgv } from "./types.js";
|
||||
|
||||
export function parseForkArgv(argv: string[]): Result<ParsedForkArgv, string> {
|
||||
if (argv.length === 0) {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
let fromRole: string | null = null;
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--from-role") {
|
||||
const r = argv[i + 1];
|
||||
if (r === undefined || r === "") {
|
||||
return err("--from-role requires a role name");
|
||||
}
|
||||
fromRole = r;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
return err(`unexpected argument: ${a}`);
|
||||
}
|
||||
return ok({ threadId, fromRole });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export { cmdKill, cmdPause, cmdResume } from "./control.js";
|
||||
export {
|
||||
createThreadDispatcher,
|
||||
dispatchFork,
|
||||
dispatchKill,
|
||||
dispatchLive,
|
||||
dispatchPause,
|
||||
dispatchPs,
|
||||
dispatchResume,
|
||||
dispatchRun,
|
||||
dispatchThreadList,
|
||||
dispatchThreadRm,
|
||||
dispatchThreadShow,
|
||||
THREAD_SUBCOMMAND_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdFork } from "./fork.js";
|
||||
export { parseForkArgv } from "./fork-argv.js";
|
||||
export { cmdThreads } from "./list.js";
|
||||
export {
|
||||
cmdLive,
|
||||
formatLiveDebugLine,
|
||||
formatLiveTimeLabel,
|
||||
LIVE_CONTENT_MAX_LINES,
|
||||
renderLiveRoleStepLines,
|
||||
} from "./live.js";
|
||||
export { cmdPs } from "./ps.js";
|
||||
export { cmdThreadRemove } from "./rm.js";
|
||||
export { cmdRun } from "./run.js";
|
||||
export { cmdThreadShow } from "./show.js";
|
||||
export type { LiveRoleRow } from "./types.js";
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
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";
|
||||
import { listHistoricalThreads } from "../../thread-scan.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
export async function cmdThreads(
|
||||
storageRoot: string,
|
||||
@@ -0,0 +1,502 @@
|
||||
import { watch } from "node:fs";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
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 {
|
||||
findLatestThreadBundleTarget,
|
||||
type LatestThreadTarget,
|
||||
resolveThreadRecord,
|
||||
} from "../../thread-scan.js";
|
||||
import type { LiveRoleRow } from "./types.js";
|
||||
|
||||
export const LIVE_CONTENT_MAX_LINES = 10;
|
||||
|
||||
export function formatLiveTimeLabel(timestampMs: number): string {
|
||||
const d = new Date(timestampMs);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export function formatLiveDebugLine(timestampMs: number, tag: string, message: string): string {
|
||||
const label = `[${formatLiveTimeLabel(timestampMs)}] [${tag}] ${message.replace(/\n/g, " ")}`;
|
||||
return dimGreyLine(label);
|
||||
}
|
||||
|
||||
export function renderLiveRoleStepLines(row: LiveRoleRow, roleDisplay: string): string[] {
|
||||
const header = `[${formatLiveTimeLabel(row.timestamp)}] ▶ ${roleDisplay}`;
|
||||
const lines: string[] = [header];
|
||||
const parts = row.content.split("\n");
|
||||
const shown = parts.slice(0, LIVE_CONTENT_MAX_LINES);
|
||||
for (const ln of shown) {
|
||||
lines.push(` ${ln}`);
|
||||
}
|
||||
const omitted = parts.length - shown.length;
|
||||
if (omitted > 0) {
|
||||
lines.push(` … (${omitted} more line${omitted === 1 ? "" : "s"})`);
|
||||
}
|
||||
lines.push(` meta: ${JSON.stringify(row.meta)}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function printSummary(result: WorkflowCompletion): void {
|
||||
printCliLine(`completed: returnCode=${result.returnCode} — ${result.summary}`);
|
||||
}
|
||||
|
||||
type InfoLiveState = {
|
||||
carry: string;
|
||||
contentOffset: number;
|
||||
};
|
||||
|
||||
type CasLiveState = {
|
||||
printedHashes: Set<string>;
|
||||
lastHead: string | null;
|
||||
completionEmitted: boolean;
|
||||
};
|
||||
|
||||
function tryParseInfoRecord(obj: Record<string, unknown>): {
|
||||
tag: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
} | null {
|
||||
const tag = obj.tag;
|
||||
const content = obj.content;
|
||||
const timestamp = obj.timestamp;
|
||||
if (
|
||||
typeof tag !== "string" ||
|
||||
typeof content !== "string" ||
|
||||
typeof timestamp !== "number" ||
|
||||
!Number.isFinite(timestamp)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { tag, content, timestamp };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const payload = await getContentMerklePayload(params.cas, params.contentHash);
|
||||
const content =
|
||||
payload !== null ? payload : `(content not in CAS; contentHash=${params.contentHash})`;
|
||||
|
||||
const row: LiveRoleRow = {
|
||||
role: params.role,
|
||||
content,
|
||||
meta: params.meta,
|
||||
timestamp: params.timestamp,
|
||||
};
|
||||
for (const outLine of renderLiveRoleStepLines(row, highlightLiveRole(row.role))) {
|
||||
printCliLine(outLine);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
idx = await readThreadsIndex(params.bundleDir);
|
||||
} catch {
|
||||
idx = {};
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function pumpNewInfoContent(infoPath: string, state: InfoLiveState): Promise<void> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(infoPath, "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length < state.contentOffset) {
|
||||
state.contentOffset = 0;
|
||||
state.carry = "";
|
||||
}
|
||||
|
||||
const chunk = text.slice(state.contentOffset);
|
||||
state.contentOffset = text.length;
|
||||
state.carry += chunk;
|
||||
|
||||
const parts = state.carry.split("\n");
|
||||
state.carry = parts.pop() ?? "";
|
||||
|
||||
for (const line of parts) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") {
|
||||
continue;
|
||||
}
|
||||
let rec: unknown;
|
||||
try {
|
||||
rec = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (rec === null || typeof rec !== "object") {
|
||||
continue;
|
||||
}
|
||||
const parsed = tryParseInfoRecord(rec as Record<string, unknown>);
|
||||
if (parsed === null) {
|
||||
continue;
|
||||
}
|
||||
printCliLine(formatLiveDebugLine(parsed.timestamp, parsed.tag, parsed.content));
|
||||
}
|
||||
}
|
||||
|
||||
type WatchPumpTask = {
|
||||
path: string;
|
||||
pump: () => Promise<number | null>;
|
||||
};
|
||||
|
||||
async function runWatchPumpStep(
|
||||
settled: () => boolean,
|
||||
pump: () => Promise<number | null>,
|
||||
closeAll: () => void,
|
||||
finish: (code: number) => void,
|
||||
): Promise<void> {
|
||||
if (settled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const code = await pump();
|
||||
if (code !== null) {
|
||||
closeAll();
|
||||
finish(code);
|
||||
}
|
||||
} catch (e) {
|
||||
closeAll();
|
||||
throw e instanceof Error ? e : new Error(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function watchLivePaths(params: { tasks: WatchPumpTask[]; signal: AbortSignal }): Promise<number> {
|
||||
const { tasks, signal } = params;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (code: number): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
const pumpChains = new Map<string, Promise<void>>();
|
||||
for (const t of tasks) {
|
||||
pumpChains.set(t.path, Promise.resolve());
|
||||
}
|
||||
|
||||
const watchers: ReturnType<typeof watch>[] = [];
|
||||
|
||||
const closeAll = (): void => {
|
||||
for (const w of watchers) {
|
||||
w.close();
|
||||
}
|
||||
};
|
||||
|
||||
function schedulePump(path: string, pump: () => Promise<number | null>): void {
|
||||
const prev = pumpChains.get(path) ?? Promise.resolve();
|
||||
const next = (async () => {
|
||||
await prev;
|
||||
await runWatchPumpStep(() => settled, pump, closeAll, finish);
|
||||
})();
|
||||
pumpChains.set(path, next);
|
||||
}
|
||||
|
||||
for (const { path, pump } of tasks) {
|
||||
const watcher = watch(path, (eventType) => {
|
||||
if (eventType === "rename") {
|
||||
return;
|
||||
}
|
||||
schedulePump(path, pump);
|
||||
});
|
||||
watchers.push(watcher);
|
||||
watcher.on("error", (errObj: Error) => {
|
||||
closeAll();
|
||||
reject(errObj);
|
||||
});
|
||||
}
|
||||
|
||||
const onAbort = (): void => {
|
||||
closeAll();
|
||||
finish(0);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
for (const { path, pump } of tasks) {
|
||||
schedulePump(path, pump);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type LiveThreadTarget = LatestThreadTarget;
|
||||
|
||||
async function resolveLiveThreadTarget(
|
||||
storageRoot: string,
|
||||
parsed: ParsedLiveArgv,
|
||||
): Promise<LiveThreadTarget | null> {
|
||||
if (parsed.latest) {
|
||||
const found = await findLatestThreadBundleTarget(storageRoot);
|
||||
if (found === null) {
|
||||
printCliError("live: no threads found");
|
||||
return null;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const id = parsed.threadId;
|
||||
if (id === null) {
|
||||
printCliError("live: internal error: missing thread id");
|
||||
return null;
|
||||
}
|
||||
const resolved = await resolveThreadRecord(storageRoot, id);
|
||||
if (resolved === null) {
|
||||
printCliError(`thread not found: ${id}`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
threadId: id,
|
||||
bundleHash: resolved.bundleHash,
|
||||
bundleDir: resolved.bundleDir,
|
||||
threadsJsonPath: join(resolved.bundleDir, "threads.json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildLiveWatchTasks(params: {
|
||||
storageRoot: string;
|
||||
target: LiveThreadTarget;
|
||||
debug: boolean;
|
||||
dataState: CasLiveState;
|
||||
infoState: InfoLiveState;
|
||||
roleFilter: string | null;
|
||||
cas: CasStore;
|
||||
}): Promise<WatchPumpTask[]> {
|
||||
const infoPath = join(
|
||||
params.storageRoot,
|
||||
"logs",
|
||||
params.target.bundleHash,
|
||||
`${params.target.threadId}.info.jsonl`,
|
||||
);
|
||||
|
||||
const tasks: WatchPumpTask[] = [
|
||||
{
|
||||
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 (params.debug && (await pathExists(infoPath))) {
|
||||
tasks.push({
|
||||
path: infoPath,
|
||||
pump: async () => {
|
||||
await pumpNewInfoContent(infoPath, params.infoState);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export async function cmdLive(storageRoot: string, parsed: ParsedLiveArgv): Promise<number> {
|
||||
const target = await resolveLiveThreadTarget(storageRoot, parsed);
|
||||
if (target === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const roleFilter = parsed.role;
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
const dataState: CasLiveState = {
|
||||
printedHashes: new Set<string>(),
|
||||
lastHead: null,
|
||||
completionEmitted: false,
|
||||
};
|
||||
|
||||
const infoState: InfoLiveState = {
|
||||
carry: "",
|
||||
contentOffset: 0,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const onSigInt = (): void => {
|
||||
controller.abort();
|
||||
};
|
||||
process.on("SIGINT", onSigInt);
|
||||
|
||||
try {
|
||||
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) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tasks = await buildLiveWatchTasks({
|
||||
storageRoot,
|
||||
target,
|
||||
debug: parsed.debug,
|
||||
dataState,
|
||||
infoState,
|
||||
roleFilter,
|
||||
cas,
|
||||
});
|
||||
|
||||
return await watchLivePaths({ tasks, signal: controller.signal });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
printCliError(`live: ${message}`);
|
||||
return 1;
|
||||
} finally {
|
||||
process.off("SIGINT", onSigInt);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { listRunningThreads } from "./thread-scan.js";
|
||||
import { listRunningThreads } from "../../thread-scan.js";
|
||||
|
||||
export async function cmdPs(storageRoot: string): Promise<string[]> {
|
||||
const rows = await listRunningThreads(storageRoot);
|
||||
@@ -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);
|
||||
}
|
||||
+6
-13
@@ -1,22 +1,15 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
err,
|
||||
generateUlid,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
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";
|
||||
|
||||
export async function cmdRun(
|
||||
storageRoot: string,
|
||||
name: string,
|
||||
prompt: string,
|
||||
isDryRun: boolean,
|
||||
maxRounds: number,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const nameOk = validateCliWorkflowName(name);
|
||||
if (!nameOk.ok) {
|
||||
@@ -47,7 +40,7 @@ export async function cmdRun(
|
||||
threadId,
|
||||
workflowName: name,
|
||||
prompt,
|
||||
options: { isDryRun, maxRounds },
|
||||
options: { depth: 0 },
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user