Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eace09826 | |||
| cb39a6693a | |||
| 36d120b745 | |||
| 86dd37b0c8 | |||
| bb0f2ca678 | |||
| ec0bc672f6 | |||
| f08ba6914c | |||
| 7dd6ab5328 | |||
| f6dd4d59a1 | |||
| d8cdc8ab88 | |||
| 20ddc5d7aa | |||
| 2846311f8d | |||
| ed0043b8ac | |||
| bee3911f3f | |||
| 4285b8b180 | |||
| 7c955fa749 | |||
| f0b7be79fb | |||
| d4f05adeba | |||
| c4c9f96117 | |||
| 633d5aeafe | |||
| 17103c1ee1 | |||
| c8a39be9bd | |||
| b304f65876 | |||
| c9010a024f | |||
| 3434e2b2be | |||
| 52282e1960 | |||
| 7a579ee67a | |||
| 7c230383ad | |||
| e604fa5f47 | |||
| 5580791686 | |||
| 3afd7a5319 | |||
| 3d1b2268b4 | |||
| 8bebe9da0f | |||
| 53a7355f0b | |||
| d99c285725 | |||
| 2505dd8d6a | |||
| 1121dfa48b | |||
| d90e29ad05 | |||
| 0727e0e8d5 | |||
| ba012d98bc | |||
| b165049a13 | |||
| 4d477c67c0 | |||
| 0d5678c961 | |||
| a8e2aa85f8 | |||
| 2a4d35399b | |||
| 391915411e | |||
| 4aaf49bfc6 | |||
| 08de1ae5eb | |||
| c91a3d1ec6 | |||
| 13d932f69c | |||
| f705d9b8ea | |||
| f84d327410 | |||
| 9c2f93629b | |||
| bcefcb9af7 | |||
| b14dce2bc6 | |||
| 85c572e770 | |||
| 9a89885ce6 | |||
| d095ceaafa | |||
| 2a0346f48b | |||
| b4e25ea002 | |||
| 77f2060e6b | |||
| 8f9a925179 | |||
| 2f3fff3536 | |||
| a7eb9814ae | |||
| a8024e6d42 | |||
| 6d94d9c85a | |||
| 49a4d08c04 | |||
| d5773369af | |||
| f49e014f41 | |||
| ab48a8169d | |||
| 2b707fb44e | |||
| 6306b23a9f | |||
| 6bb8cf8315 | |||
| 93b7947d7c | |||
| 9584a86fb7 | |||
| defc0afc27 | |||
| 9f6633d5bf | |||
| 7dadf874e1 | |||
| 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 |
@@ -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!"
|
||||||
@@ -6,3 +6,7 @@ tsconfig.tsbuildinfo
|
|||||||
.npmrc
|
.npmrc
|
||||||
|
|
||||||
bunfig.toml
|
bunfig.toml
|
||||||
|
xiaoju/
|
||||||
|
solve-issue-entry.ts
|
||||||
|
packages/workflow-template-develop/develop.esm.js
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ workflow/
|
|||||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||||
|
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||||
@@ -40,7 +41,7 @@ workflow/
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||||
- Packages use `workspace:*` protocol
|
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||||
|
|
||||||
## Language & Paradigm
|
## Language & Paradigm
|
||||||
|
|
||||||
@@ -245,61 +246,47 @@ bun run format # biome format --write
|
|||||||
bun test # run tests
|
bun test # run tests
|
||||||
```
|
```
|
||||||
|
|
||||||
### Publishing to Gitea npm Registry
|
### Version Management & Publishing
|
||||||
|
|
||||||
All public `@uncaged/*` packages are published to the Gitea npm registry at `git.shazhou.work`. Workflow workspaces consume packages from this registry via `bunfig.toml`.
|
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Publish all packages (bun pm pack resolves workspace:* → actual versions)
|
# 1. After making changes, add a changeset describing the change
|
||||||
bun run publish:gitea
|
bun changeset
|
||||||
|
|
||||||
# Dry run — see what would be published
|
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||||
bun run publish:gitea:dry
|
bun version
|
||||||
|
|
||||||
|
# 3. Build, test, and publish to npmjs
|
||||||
|
bun release
|
||||||
```
|
```
|
||||||
|
|
||||||
Prerequisites: `.npmrc` in monorepo root with Gitea auth token (`//git.shazhou.work/api/packages/shazhou/npm/:_authToken=<token>`).
|
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||||
|
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||||
|
- Each package has auto-generated `CHANGELOG.md`
|
||||||
|
|
||||||
### Workflow Workspace Setup
|
### Consuming @uncaged/* Packages
|
||||||
|
|
||||||
External workflow repos (e.g. `xingyue-workflows`) use the Gitea registry for `@uncaged/*` packages. Add a `bunfig.toml`:
|
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||||
|
|
||||||
```toml
|
|
||||||
[install.scopes]
|
|
||||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then `bun install` resolves `@uncaged/*` from Gitea, all other packages from npmjs.
|
|
||||||
|
|
||||||
### Cross-repo Development (bun link)
|
|
||||||
|
|
||||||
Alternative for development against un-published local changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run link # Register all packages (from monorepo root)
|
|
||||||
bun run link:consume # Link into CWD's project (⚠️ don't bun install after)
|
|
||||||
bun run link:unlink # Restore original deps
|
|
||||||
```
|
|
||||||
|
|
||||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||||
|
|
||||||
The recommended development flow for building workflows:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||||
│ bun run publish:gitea — auto topo-sort, bun pm pack → npm publish
|
│ bun release — build + test + changeset publish
|
||||||
▼
|
▼
|
||||||
git.shazhou.work npm registry — @uncaged/* scoped packages
|
npmjs.org — @uncaged/* scoped packages (public)
|
||||||
│ bun install — via bunfig.toml scoped registry
|
│ bun install
|
||||||
▼
|
▼
|
||||||
my-workflows/ (workspace) — bunfig.toml + normal package.json
|
my-workflows/ (workspace) — normal package.json
|
||||||
│ bun run build:develop — bun build → single .esm.js
|
│ bun run build:develop — bun build → single .esm.js
|
||||||
▼
|
▼
|
||||||
uncaged-workflow workflow add — register bundle locally
|
uncaged-workflow workflow add — register bundle locally
|
||||||
uncaged-workflow run — execute workflow
|
uncaged-workflow run — execute workflow
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Monorepo changes** → `bun run publish:gitea` (packages auto-discovered from `packages/*/`, topologically sorted, `workspace:*` resolved to real versions)
|
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||||
2. **Workspace** → `bun install` fetches latest from Gitea, `bun install` is safe to run anytime
|
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
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>`
|
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||||
|
|
||||||
|
|||||||
+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": {
|
"files": {
|
||||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!**/dist",
|
||||||
|
"!**/node_modules",
|
||||||
|
"!packages/workflow/workflow",
|
||||||
|
"!xiaoju/scripts/bundle.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
A 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.
|
The implementation lives in **21** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||||
|
|
||||||
## Package map
|
## Package map
|
||||||
|
|
||||||
@@ -26,10 +26,13 @@ Grouped by responsibility (npm name → folder).
|
|||||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
|
| 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. |
|
| 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-hermes` → `workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
|
||||||
|
| | `@uncaged/workflow-agent-office` → `workflow-agent-office` | `AdapterFn` via `office-agent` CLI; generates or edits Word documents, stores outputs per threadId. |
|
||||||
|
| | `@uncaged/workflow-agent-docx-diff` → `workflow-agent-docx-diff` | `AdapterFn` via `docx-diff` CLI; produces Word-format diff reports for document edit workflows. |
|
||||||
| | `@uncaged/workflow-agent-llm` → `workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
|
| | `@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. |
|
| 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. |
|
| 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. |
|
| | `@uncaged/workflow-template-solve-issue` → `workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
|
||||||
|
| | `@uncaged/workflow-template-document` → `workflow-template-document` | Document generation/editing workflow definition (writer + differ roles, moderator table, descriptor). |
|
||||||
| Dashboard | `@uncaged/workflow-dashboard` → `workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
|
| 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)
|
## Dependency graph (workspace packages)
|
||||||
@@ -265,4 +268,4 @@ Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNM
|
|||||||
| **Single-file ESM** | Hash = version, self-contained bundle |
|
| **Single-file ESM** | Hash = version, self-contained bundle |
|
||||||
| **No daemon** | OS handles process lifecycle |
|
| **No daemon** | OS handles process lifecycle |
|
||||||
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||||
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
| **21-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
|||||||
|
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
|
||||||
|
|
||||||
|
**日期:** 2026-05-18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
|
||||||
|
|
||||||
|
| 包 | npm name | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
|
||||||
|
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
|
||||||
|
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
|
||||||
|
|
||||||
|
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、`workflow-template-document`
|
||||||
|
|
||||||
|
### Thread 启动输入
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/types.ts
|
||||||
|
type DocumentStartInput = {
|
||||||
|
prompt: string; // 用户指令
|
||||||
|
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
|
||||||
|
|
||||||
|
### 角色与 Meta
|
||||||
|
|
||||||
|
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const writerMetaSchema = z.discriminatedUnion("mode", [
|
||||||
|
z.object({
|
||||||
|
mode: z.literal("generate"),
|
||||||
|
outputDocx: z.string(), // 生成产物绝对路径
|
||||||
|
sourceDocx: z.null(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
mode: z.literal("edit"),
|
||||||
|
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
|
||||||
|
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
type WriterMeta = z.infer<typeof writerMetaSchema>;
|
||||||
|
|
||||||
|
// differ:仅编辑模式执行
|
||||||
|
const differMetaSchema = z.object({
|
||||||
|
sourceDocx: z.string(),
|
||||||
|
modifiedDocx: z.string(),
|
||||||
|
diffDocx: z.string(),
|
||||||
|
});
|
||||||
|
type DifferMeta = z.infer<typeof differMetaSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
两个角色的 `systemPrompt` 均为 `""`。
|
||||||
|
|
||||||
|
### 调度表
|
||||||
|
|
||||||
|
```
|
||||||
|
START → writer ──(mode = "edit")──→ differ → END
|
||||||
|
↘(mode = "generate")→ END
|
||||||
|
```
|
||||||
|
|
||||||
|
### 公开导出
|
||||||
|
|
||||||
|
template 导出两个对象供消费方使用:
|
||||||
|
|
||||||
|
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow` 的 `def` 参数
|
||||||
|
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// bundle 侧用法
|
||||||
|
export const descriptor = buildDocumentDescriptor();
|
||||||
|
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 包文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/workflow-template-document/
|
||||||
|
src/
|
||||||
|
types.ts # DocumentStartInput
|
||||||
|
roles/
|
||||||
|
writer.ts # writerMetaSchema, WriterMeta, writerRole
|
||||||
|
differ.ts # differMetaSchema, DifferMeta, differRole
|
||||||
|
index.ts
|
||||||
|
roles.ts # DocumentMeta, documentRoles
|
||||||
|
moderator.ts # writerIsEditMode condition + documentTable
|
||||||
|
definition.ts # documentWorkflowDefinition
|
||||||
|
descriptor.ts # buildDocumentDescriptor()
|
||||||
|
index.ts
|
||||||
|
__tests__/
|
||||||
|
moderator.test.ts
|
||||||
|
package.json
|
||||||
|
tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
|
"@uncaged/workflow-register": "workspace:^",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、`workflow-agent-office`
|
||||||
|
|
||||||
|
### office-agent CLI 接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成模式:在 CWD 生成 output.docx
|
||||||
|
office-agent create "<prompt>" -o output.docx
|
||||||
|
|
||||||
|
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
|
||||||
|
office-agent edit modified.docx "<instruction>"
|
||||||
|
```
|
||||||
|
|
||||||
|
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
|
||||||
|
- 输出文件落到调用方设定的 CWD
|
||||||
|
- 退出码 0 = 成功,非零 = 失败
|
||||||
|
|
||||||
|
### 文件命名约定
|
||||||
|
|
||||||
|
| 模式 | 文件 | 路径 |
|
||||||
|
|---|---|---|
|
||||||
|
| generate | 输出 | `<outputDir>/output.docx` |
|
||||||
|
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
|
||||||
|
| edit | 修改后产物 | `<outputDir>/modified.docx` |
|
||||||
|
|
||||||
|
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx`,`original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
|
||||||
|
|
||||||
|
### 执行流程
|
||||||
|
|
||||||
|
**生成模式(`inputDocx = null`):**
|
||||||
|
1. `mkdir -p <outputDir>`(`<config.outputDir>/<ctx.threadId>`)
|
||||||
|
2. `const command = config.command ?? "office-agent"`
|
||||||
|
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
|
||||||
|
4. 验证 `outputDir/output.docx` 存在
|
||||||
|
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
|
||||||
|
|
||||||
|
**编辑模式(`inputDocx ≠ null`):**
|
||||||
|
1. `mkdir -p <outputDir>`
|
||||||
|
2. `copyFile(inputDocx, <outputDir>/original.docx)`
|
||||||
|
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
|
||||||
|
4. `const command = config.command ?? "office-agent"`
|
||||||
|
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
|
||||||
|
6. 验证 `outputDir/modified.docx` 存在
|
||||||
|
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
|
||||||
|
|
||||||
|
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
||||||
|
|
||||||
|
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
|
||||||
|
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
|
||||||
|
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||||
|
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
|
||||||
|
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
|
||||||
|
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||||
|
return { meta, childThread: null };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type OfficeAgentConfig = {
|
||||||
|
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
|
||||||
|
command: string | null; // null → runner 内 resolve 为 "office-agent"
|
||||||
|
timeout: number | null; // null → 不设超时;单位 ms
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!result.ok) {
|
||||||
|
const e = result.error;
|
||||||
|
if (e.kind === "non_zero_exit")
|
||||||
|
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
|
||||||
|
if (e.kind === "timeout")
|
||||||
|
throw new Error("office-agent: timed out");
|
||||||
|
// "spawn_failed"
|
||||||
|
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
if (!existsSync(expectedPath))
|
||||||
|
throw new Error(`office-agent: output file not found: ${expectedPath}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### packageDescriptor
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/package-descriptor.ts
|
||||||
|
export const packageDescriptor: PackageDescriptor = {
|
||||||
|
name: "@uncaged/workflow-agent-office",
|
||||||
|
version: "0.1.0",
|
||||||
|
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["outputDir"],
|
||||||
|
properties: {
|
||||||
|
outputDir: { type: "string", description: "Root directory for workflow outputs." },
|
||||||
|
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
|
||||||
|
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 包文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/workflow-agent-office/
|
||||||
|
src/
|
||||||
|
types.ts # OfficeAgentConfig, OfficeAgentOpt
|
||||||
|
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
|
||||||
|
agent.ts # createOfficeAgent(): AdapterFn
|
||||||
|
package-descriptor.ts # packageDescriptor
|
||||||
|
index.ts
|
||||||
|
__tests__/
|
||||||
|
runner.test.ts
|
||||||
|
agent.test.ts
|
||||||
|
package.json
|
||||||
|
tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
|
"@uncaged/workflow-util-agent": "workspace:^"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、`workflow-agent-docx-diff`
|
||||||
|
|
||||||
|
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
|
||||||
|
|
||||||
|
### docx-diff 退出码约定
|
||||||
|
|
||||||
|
| 退出码 | 含义 | runner 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
|
||||||
|
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
|
||||||
|
| 2+ | 错误 | throw |
|
||||||
|
|
||||||
|
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
|
||||||
|
|
||||||
|
### 执行流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
|
||||||
|
2. 验证 mode === "edit"(否则 throw)
|
||||||
|
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
|
||||||
|
4. const command = config.command ?? "docx-diff"
|
||||||
|
5. spawnCli(command,
|
||||||
|
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
|
||||||
|
{ cwd: null, timeoutMs: null })
|
||||||
|
exit 0 或 1 → 验证 diffDocx 存在
|
||||||
|
exit 2+ → throw
|
||||||
|
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
|
||||||
|
```
|
||||||
|
|
||||||
|
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
|
||||||
|
return <T>(_prompt: string, schema: z.ZodType<T>) =>
|
||||||
|
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||||
|
const writerStep = ctx.steps.find(s => s.role === "writer");
|
||||||
|
if (!writerStep) throw new Error("differ: no writer step found");
|
||||||
|
const writerMeta = writerStep.meta as WriterMeta;
|
||||||
|
if (writerMeta.mode !== "edit")
|
||||||
|
throw new Error("differ: writer did not run in edit mode");
|
||||||
|
const raw = await runDocxDiff(config, writerMeta);
|
||||||
|
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||||
|
return { meta, childThread: null };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type DocxDiffAgentConfig = {
|
||||||
|
command: string | null; // null → runner 内 resolve 为 "docx-diff"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### packageDescriptor
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const packageDescriptor: PackageDescriptor = {
|
||||||
|
name: "@uncaged/workflow-agent-docx-diff",
|
||||||
|
version: "0.1.0",
|
||||||
|
capabilities: ["docx-diff-cli", "docx-diff-report"],
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 包文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/workflow-agent-docx-diff/
|
||||||
|
src/
|
||||||
|
types.ts # DocxDiffAgentConfig
|
||||||
|
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
|
||||||
|
agent.ts # createDocxDiffAgent(): AdapterFn
|
||||||
|
package-descriptor.ts # packageDescriptor
|
||||||
|
index.ts
|
||||||
|
__tests__/
|
||||||
|
runner.test.ts
|
||||||
|
agent.test.ts
|
||||||
|
package.json
|
||||||
|
tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
|
"@uncaged/workflow-util-agent": "workspace:^",
|
||||||
|
"@uncaged/workflow-template-document": "workspace:^"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、外部 bundle(外部 workspace 消费)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
|
||||||
|
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
|
||||||
|
import {
|
||||||
|
buildDocumentDescriptor,
|
||||||
|
documentWorkflowDefinition,
|
||||||
|
} from "@uncaged/workflow-template-document";
|
||||||
|
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||||
|
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
|
||||||
|
|
||||||
|
export const descriptor = buildDocumentDescriptor();
|
||||||
|
export const run = createWorkflow(documentWorkflowDefinition, {
|
||||||
|
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
|
||||||
|
overrides: { differ: createDocxDiffAgent() },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不在范围内
|
||||||
|
|
||||||
|
- 重试逻辑(失败直接 throw)
|
||||||
|
- office-agent server 的启停管理(假设 server 已在运行)
|
||||||
|
- docx-diff HTML/terminal 格式输出(仅 docx)
|
||||||
|
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
# `uwf` — Stateless Workflow CLI
|
||||||
|
|
||||||
|
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CLI Design
|
||||||
|
|
||||||
|
### 1.1 命令总览
|
||||||
|
|
||||||
|
```
|
||||||
|
# thread 组
|
||||||
|
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
|
||||||
|
uwf thread step <thread-id> [--agent] # 单步执行
|
||||||
|
uwf thread show <thread-id> # thread-id → head 查询
|
||||||
|
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
|
||||||
|
uwf thread kill <thread-id> # 终结 thread,归档
|
||||||
|
|
||||||
|
# workflow 组
|
||||||
|
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
|
||||||
|
uwf workflow show <workflow-id> # 查看 workflow 定义
|
||||||
|
uwf workflow list # 列出已注册 workflows
|
||||||
|
```
|
||||||
|
|
||||||
|
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
|
||||||
|
|
||||||
|
### 1.2 `uwf thread start`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<workflow>` — workflow 名或 CAS hash
|
||||||
|
- `-p` — 用户 prompt(必填)
|
||||||
|
|
||||||
|
**输出(JSON to stdout):**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
|
||||||
|
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**做的事:**
|
||||||
|
1. 解析 workflow(名字查 registry → CAS hash)
|
||||||
|
2. 生成 thread ULID
|
||||||
|
3. 写 StartNode 到 CAS
|
||||||
|
4. 在 threads.yaml 中记录链头 → StartNode hash
|
||||||
|
5. 输出 JSON
|
||||||
|
|
||||||
|
### 1.3 `uwf thread step`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
|
||||||
|
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出(JSON to stdout):**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"workflow": "4KNM2PXR3B1QW",
|
||||||
|
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
|
||||||
|
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
|
||||||
|
"done": false // true = moderator 返回 END,thread 已归档
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
|
||||||
|
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
|
||||||
|
|
||||||
|
详细信息通过 `uwf thread show <thread-id>` 或 `json-cas get <head>` 查看。
|
||||||
|
|
||||||
|
**做的事:**
|
||||||
|
1. 读链头 → 当前 StepNode(或 StartNode)
|
||||||
|
2. 收集 thread 历史(遍历链)
|
||||||
|
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
|
||||||
|
4. 若 END → 归档 thread,输出最后链头,退出
|
||||||
|
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
||||||
|
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
||||||
|
7. 更新链头指针
|
||||||
|
8. 再次调 moderator(基于新 StepNode)判断 done
|
||||||
|
9. 输出 JSON
|
||||||
|
|
||||||
|
### 1.4 `uwf thread show`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出(JSON to stdout):**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"workflow": "4KNM2PXR3B1QW",
|
||||||
|
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
|
||||||
|
"head": "8FWKR3TN5V1QA",
|
||||||
|
"done": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
纯 thread-id → head 查询。详细内容用 `json-cas get <head>` 或 `json-cas walk <head>` 查看。
|
||||||
|
|
||||||
|
### 1.5 Agent CLI 协议
|
||||||
|
|
||||||
|
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uwf-hermes <thread-id> <role>
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
||||||
|
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
|
||||||
|
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
|
||||||
|
- agent 执行实际逻辑,agent-kit 负责 extract
|
||||||
|
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
||||||
|
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
||||||
|
- 所有配置从环境变量读(LLM model、API key、extractor config)
|
||||||
|
- exit 0 = 成功,非 0 = 失败
|
||||||
|
|
||||||
|
**stdout 输出:**
|
||||||
|
|
||||||
|
```
|
||||||
|
8FWKR3TN5V1QA
|
||||||
|
```
|
||||||
|
|
||||||
|
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. CAS 结构定义
|
||||||
|
|
||||||
|
### 2.1 类型层级
|
||||||
|
|
||||||
|
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
|
||||||
|
|
||||||
|
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
|
||||||
|
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
|
||||||
|
|
||||||
|
### 2.2 数据节点
|
||||||
|
|
||||||
|
#### `Workflow`
|
||||||
|
|
||||||
|
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: <workflow-schema-hash>
|
||||||
|
payload:
|
||||||
|
name: "solve-issue"
|
||||||
|
description: "End-to-end issue resolution"
|
||||||
|
roles:
|
||||||
|
planner:
|
||||||
|
description: "Creates implementation plan"
|
||||||
|
systemPrompt: "You are a planning agent..."
|
||||||
|
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||||
|
developer:
|
||||||
|
description: "Implements code changes"
|
||||||
|
systemPrompt: "You are a developer agent..."
|
||||||
|
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||||
|
reviewer:
|
||||||
|
description: "Reviews code changes"
|
||||||
|
systemPrompt: "You are a code reviewer..."
|
||||||
|
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||||
|
conditions:
|
||||||
|
needsClarification:
|
||||||
|
description: "Planner requests clarification from user"
|
||||||
|
expression: "$exists(steps[-1].output.needsClarification)"
|
||||||
|
notApproved:
|
||||||
|
description: "Reviewer rejected the implementation"
|
||||||
|
expression: "steps[-1].output.approved = false"
|
||||||
|
graph:
|
||||||
|
$START:
|
||||||
|
- role: "planner"
|
||||||
|
condition: null # 无条件(fallback)
|
||||||
|
planner:
|
||||||
|
- role: "developer"
|
||||||
|
condition: "needsClarification"
|
||||||
|
- role: "$END"
|
||||||
|
condition: null
|
||||||
|
developer:
|
||||||
|
- role: "reviewer"
|
||||||
|
condition: null
|
||||||
|
reviewer:
|
||||||
|
- role: "developer"
|
||||||
|
condition: "notApproved"
|
||||||
|
- role: "$END"
|
||||||
|
condition: null
|
||||||
|
```
|
||||||
|
|
||||||
|
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||||
|
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
||||||
|
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
||||||
|
- `condition` 引用 conditions 中的 key,`null` = fallback
|
||||||
|
- 按数组顺序求值,第一个匹配的 transition 胜出
|
||||||
|
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
||||||
|
|
||||||
|
JSONata 表达式的求值上下文:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"start": { // StartNode 信息
|
||||||
|
"workflow": "4KNM2PXR3B1QW",
|
||||||
|
"prompt": "Fix the login bug..."
|
||||||
|
},
|
||||||
|
"steps": [ // 所有已完成 steps,从旧到新
|
||||||
|
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
|
||||||
|
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
|
||||||
|
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
|
||||||
|
|
||||||
|
#### `StartNode`(Thread 起点)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: <start-node-schema-hash>
|
||||||
|
payload:
|
||||||
|
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
||||||
|
prompt: "Fix the login bug..."
|
||||||
|
```
|
||||||
|
|
||||||
|
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
|
||||||
|
- 没有 agent binding — 运行时从 config.yaml 解析
|
||||||
|
|
||||||
|
#### `StepNode`(Thread 每一步)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: <step-node-schema-hash>
|
||||||
|
payload:
|
||||||
|
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
||||||
|
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
||||||
|
role: "developer"
|
||||||
|
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
|
||||||
|
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||||
|
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
||||||
|
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
||||||
|
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
|
||||||
|
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||||
|
- `agent` — 纯字符串,不是 CAS 节点
|
||||||
|
|
||||||
|
### 2.3 链式结构
|
||||||
|
|
||||||
|
```
|
||||||
|
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
StepNode (step 3)
|
||||||
|
├── start ──→ StartNode
|
||||||
|
│ ├── workflow → CAS(Workflow)
|
||||||
|
│ └── prompt: "Fix..."
|
||||||
|
├── prev ──→ StepNode (step 2)
|
||||||
|
│ ├── start ──→ (same StartNode)
|
||||||
|
│ ├── prev ──→ StepNode (step 1)
|
||||||
|
│ │ ├── start ──→ (same StartNode)
|
||||||
|
│ │ ├── prev: null
|
||||||
|
│ │ ├── role: "planner"
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── role: "developer"
|
||||||
|
│ └── ...
|
||||||
|
├── role: "reviewer"
|
||||||
|
├── output → CAS({ approved: true })
|
||||||
|
├── detail → CAS(raw output | sub-workflow terminal node)
|
||||||
|
└── agent: "uwf-hermes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 可变状态
|
||||||
|
|
||||||
|
系统两个顶层 YAML 文件和一个 env 文件:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.uncaged/workflow/config.yaml — 全局配置
|
||||||
|
providers:
|
||||||
|
openai:
|
||||||
|
baseUrl: "https://api.openai.com/v1"
|
||||||
|
apiKeyEnv: "OPENAI_API_KEY"
|
||||||
|
anthropic:
|
||||||
|
baseUrl: "https://api.anthropic.com/v1"
|
||||||
|
apiKeyEnv: "ANTHROPIC_API_KEY"
|
||||||
|
openrouter:
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1"
|
||||||
|
apiKeyEnv: "OPENROUTER_API_KEY"
|
||||||
|
|
||||||
|
models:
|
||||||
|
sonnet:
|
||||||
|
provider: "openrouter"
|
||||||
|
name: "anthropic/claude-sonnet-4"
|
||||||
|
gpt4o-mini:
|
||||||
|
provider: "openai"
|
||||||
|
name: "gpt-4o-mini"
|
||||||
|
|
||||||
|
agents:
|
||||||
|
hermes:
|
||||||
|
command: "uwf-hermes"
|
||||||
|
args: []
|
||||||
|
cursor:
|
||||||
|
command: "uwf-cursor"
|
||||||
|
args: []
|
||||||
|
|
||||||
|
defaultAgent: "hermes"
|
||||||
|
agentOverrides:
|
||||||
|
solve-issue:
|
||||||
|
developer: "cursor"
|
||||||
|
|
||||||
|
defaultModel: "sonnet"
|
||||||
|
modelOverrides:
|
||||||
|
extract: "gpt4o-mini"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.uncaged/workflow/threads.yaml — active thread 链头指针
|
||||||
|
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
|
||||||
|
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
|
||||||
|
```
|
||||||
|
|
||||||
|
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ~/.uncaged/workflow/.env — 敏感信息(API keys)
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
OPENROUTER_API_KEY=sk-or-...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
|
||||||
|
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
|
||||||
|
- `threads.yaml` — 运行时状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 包结构
|
||||||
|
|
||||||
|
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`。
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
├── cli-uwf/ # @uncaged/cli-uwf — uwf CLI(thread/workflow 命令)
|
||||||
|
├── uwf-moderator/ # @uncaged/uwf-moderator — JSONata moderator 引擎
|
||||||
|
├── uwf-agent-kit/ # @uncaged/uwf-agent-kit — Agent CLI 框架(含 extractor)
|
||||||
|
├── uwf-agent-hermes/ # @uncaged/uwf-agent-hermes — uwf-hermes CLI
|
||||||
|
├── uwf-agent-cursor/ # @uncaged/uwf-agent-cursor — uwf-cursor CLI
|
||||||
|
└── uwf-protocol/ # @uncaged/uwf-protocol — 共享类型定义
|
||||||
|
```
|
||||||
|
|
||||||
|
**外部依赖:**
|
||||||
|
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
|
||||||
|
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
|
||||||
|
|
||||||
|
**现有包全部保留不动**,新旧并存,逐步迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 关键数据类型
|
||||||
|
|
||||||
|
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||||
|
|
||||||
|
### 4.1 公共类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** CAS hash — XXH64, 13-char Crockford Base32 */
|
||||||
|
type CasRef = string;
|
||||||
|
|
||||||
|
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||||
|
type ThreadId = string;
|
||||||
|
|
||||||
|
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||||
|
type StepRecord = {
|
||||||
|
role: string;
|
||||||
|
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
|
||||||
|
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||||
|
agent: string; // 实际使用的 agent 命令(纯字符串)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Workflow 定义
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type RoleDefinition = {
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||||
|
};
|
||||||
|
|
||||||
|
type Transition = {
|
||||||
|
role: string; // 目标 role 名 或 "$END"
|
||||||
|
condition: string | null; // 引用 conditions 中的 key,null = fallback
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConditionDefinition = {
|
||||||
|
description: string;
|
||||||
|
expression: string; // JSONata expression
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkflowPayload = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
roles: Record<string, RoleDefinition>;
|
||||||
|
conditions: Record<string, ConditionDefinition>;
|
||||||
|
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Thread 节点
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type StartNodePayload = {
|
||||||
|
workflow: CasRef; // cas_ref → Workflow
|
||||||
|
prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StepNodePayload = StepRecord & {
|
||||||
|
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
|
||||||
|
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 JSONata 求值上下文
|
||||||
|
|
||||||
|
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** JSONata 上下文中的 step — output 被展开 */
|
||||||
|
type StepContext = Omit<StepRecord, "output"> & {
|
||||||
|
output: unknown; // 展开后的 CAS 节点内容,非 hash
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModeratorContext = {
|
||||||
|
start: StartNodePayload;
|
||||||
|
steps: StepContext[]; // 从旧到新
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 CLI 输出
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** uwf thread start */
|
||||||
|
type StartOutput = {
|
||||||
|
workflow: CasRef;
|
||||||
|
thread: ThreadId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread step / uwf thread show */
|
||||||
|
type StepOutput = {
|
||||||
|
workflow: CasRef;
|
||||||
|
thread: ThreadId;
|
||||||
|
head: CasRef;
|
||||||
|
done: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread list */
|
||||||
|
type ThreadListItem = {
|
||||||
|
thread: ThreadId;
|
||||||
|
workflow: CasRef;
|
||||||
|
head: CasRef;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Alias types for config references */
|
||||||
|
type AgentAlias = string;
|
||||||
|
type ModelAlias = string;
|
||||||
|
type ProviderAlias = string;
|
||||||
|
type WorkflowName = string;
|
||||||
|
type RoleName = string;
|
||||||
|
type Scenario = string; // e.g. "extract"
|
||||||
|
|
||||||
|
type ProviderConfig = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKeyEnv: string; // env var name to read API key from
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModelConfig = {
|
||||||
|
provider: ProviderAlias;
|
||||||
|
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentConfig = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ~/.uncaged/workflow/config.yaml */
|
||||||
|
type WorkflowConfig = {
|
||||||
|
providers: Record<ProviderAlias, ProviderConfig>;
|
||||||
|
models: Record<ModelAlias, ModelConfig>;
|
||||||
|
agents: Record<AgentAlias, AgentConfig>;
|
||||||
|
defaultAgent: AgentAlias;
|
||||||
|
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
||||||
|
defaultModel: ModelAlias;
|
||||||
|
modelOverrides: Record<Scenario, ModelAlias> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ~/.uncaged/workflow/threads.yaml */
|
||||||
|
type ThreadsIndex = Record<ThreadId, CasRef>;
|
||||||
|
// ^ thread-id ^ head StepNode/StartNode hash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 类型关系图
|
||||||
|
|
||||||
|
```
|
||||||
|
WorkflowConfig (config.yaml)
|
||||||
|
ThreadsIndex (threads.yaml) ← 唯二可变状态
|
||||||
|
│
|
||||||
|
│ thread-id → head hash
|
||||||
|
▼
|
||||||
|
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
|
||||||
|
│ │ │
|
||||||
|
├── start → StartNodePayload│ │ (output 展开)
|
||||||
|
├── prev → StepNodePayload │ │
|
||||||
|
│ ├── role ├── role
|
||||||
|
│ ├── output (CasRef) ├── output (展开)
|
||||||
|
│ ├── detail (CasRef) ├── detail (CasRef)
|
||||||
|
│ └── agent (string) └── agent (string)
|
||||||
|
│
|
||||||
|
└── start.workflow → WorkflowPayload
|
||||||
|
├── roles: Record<name, RoleDefinition>
|
||||||
|
├── conditions: Record<name, JSONata>
|
||||||
|
└── graph: Record<role, Transition[]>
|
||||||
|
```
|
||||||
+5
-6
@@ -6,18 +6,17 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bunx tsc --build",
|
"build": "bunx tsc --build",
|
||||||
"check": "bunx tsc --build && biome check .",
|
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"test": "bun run --filter '*' test",
|
"test": "bun run --filter '*' test",
|
||||||
"link": "./scripts/link-all.sh",
|
"changeset": "bunx changeset",
|
||||||
"link:consume": "./scripts/link-all.sh --consume",
|
"version": "bunx changeset version",
|
||||||
"link:unlink": "./scripts/link-all.sh --unlink",
|
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||||
"publish:gitea": "./scripts/publish-all.sh",
|
|
||||||
"publish:gitea:dry": "./scripts/publish-all.sh --dry-run"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.14",
|
"@biomejs/biome": "^2.4.14",
|
||||||
|
"@changesets/cli": "^2.31.0",
|
||||||
"@types/node": "^25.7.0",
|
"@types/node": "^25.7.0",
|
||||||
"@types/xxhashjs": "^0.2.4",
|
"@types/xxhashjs": "^0.2.4",
|
||||||
"bun-types": "^1.3.13"
|
"bun-types": "^1.3.13"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/cli-uwf",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"uwf": "./src/cli.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/json-cas": "^0.1.3",
|
||||||
|
"@uncaged/json-cas-fs": "^0.1.2",
|
||||||
|
"@uncaged/uwf-agent-kit": "workspace:^",
|
||||||
|
"@uncaged/uwf-moderator": "workspace:^",
|
||||||
|
"@uncaged/uwf-protocol": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
|
"commander": "^14.0.3",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"yaml": "^2.8.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+281
@@ -0,0 +1,281 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { Command } from "commander";
|
||||||
|
|
||||||
|
import {
|
||||||
|
cmdThreadKill,
|
||||||
|
cmdThreadList,
|
||||||
|
cmdThreadShow,
|
||||||
|
cmdThreadStart,
|
||||||
|
cmdThreadStep,
|
||||||
|
} from "./commands/thread.js";
|
||||||
|
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||||
|
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||||
|
import {
|
||||||
|
cmdCasCat,
|
||||||
|
cmdCasGet,
|
||||||
|
cmdCasHas,
|
||||||
|
cmdCasPut,
|
||||||
|
cmdCasRefs,
|
||||||
|
cmdCasSchemaGet,
|
||||||
|
cmdCasSchemaList,
|
||||||
|
cmdCasWalk,
|
||||||
|
} from "./commands/cas.js";
|
||||||
|
import { resolveStorageRoot } from "./store.js";
|
||||||
|
import { type OutputFormat, formatOutput } from "./format.js";
|
||||||
|
|
||||||
|
function writeOutput(data: unknown): void {
|
||||||
|
const fmt = program.opts().format as OutputFormat;
|
||||||
|
process.stdout.write(`${formatOutput(data, fmt)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(action: () => Promise<void>): void {
|
||||||
|
action().catch((e: unknown) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program.name("uwf").description("Stateless workflow CLI");
|
||||||
|
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||||
|
|
||||||
|
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("put")
|
||||||
|
.description("Register a workflow from YAML")
|
||||||
|
.argument("<file>", "Workflow YAML file")
|
||||||
|
.action((file: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdWorkflowPut(storageRoot, file);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("show")
|
||||||
|
.description("Show a workflow by name or CAS hash")
|
||||||
|
.argument("<id>", "Workflow name or hash")
|
||||||
|
.action((id: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdWorkflowShow(storageRoot, id);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("list")
|
||||||
|
.description("List registered workflows")
|
||||||
|
.action(() => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdWorkflowList(storageRoot);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const thread = program.command("thread").description("Thread lifecycle and execution");
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("start")
|
||||||
|
.description("Create a thread without executing")
|
||||||
|
.argument("<workflow>", "Workflow name or hash")
|
||||||
|
.requiredOption("-p, --prompt <text>", "User prompt")
|
||||||
|
.action((workflow: string, opts: { prompt: string }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("step")
|
||||||
|
.description("Execute one step")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.option("--agent <cmd>", "Override agent command")
|
||||||
|
.action((threadId: string, opts: { agent: string | undefined }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const agentOverride = opts.agent ?? null;
|
||||||
|
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("show")
|
||||||
|
.description("Show thread head pointer")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action((threadId: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadShow(storageRoot, threadId);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("list")
|
||||||
|
.description("List active threads")
|
||||||
|
.option("--all", "Include archived threads")
|
||||||
|
.action((opts: { all: boolean }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadList(storageRoot, opts.all);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("kill")
|
||||||
|
.description("Terminate and archive a thread")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action((threadId: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadKill(storageRoot, threadId);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("setup")
|
||||||
|
.description("Configure provider, model, and agent")
|
||||||
|
.option("--provider <name>", "Provider name")
|
||||||
|
.option("--base-url <url>", "OpenAI-compatible API base URL")
|
||||||
|
.option("--api-key <key>", "API key")
|
||||||
|
.option("--model <name>", "Default model name")
|
||||||
|
.option("--agent <name>", "Default agent alias")
|
||||||
|
.action((opts: {
|
||||||
|
provider?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
model?: string;
|
||||||
|
agent?: string;
|
||||||
|
}) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
|
||||||
|
const result = await cmdSetup({
|
||||||
|
provider: opts.provider,
|
||||||
|
baseUrl: opts.baseUrl,
|
||||||
|
apiKey: opts.apiKey,
|
||||||
|
model: opts.model,
|
||||||
|
agent: opts.agent ?? undefined,
|
||||||
|
storageRoot,
|
||||||
|
});
|
||||||
|
writeOutput(result);
|
||||||
|
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
|
||||||
|
await cmdSetupInteractive(storageRoot);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cas = program.command("cas").description("Content-addressable storage operations");
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("get")
|
||||||
|
.description("Read a CAS node as JSON")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasGet(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("cat")
|
||||||
|
.description("Output a CAS node (--payload for payload only)")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.option("--payload", "Output only the payload")
|
||||||
|
.action((hash: string, opts: { payload?: boolean }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasCat(storageRoot, hash, opts));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("put")
|
||||||
|
.description("Store a node, print its hash")
|
||||||
|
.argument("<type-hash>", "Type (schema) hash")
|
||||||
|
.argument("<data>", "JSON file path or inline JSON string")
|
||||||
|
.action((typeHash: string, data: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasPut(storageRoot, typeHash, data));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("has")
|
||||||
|
.description("Check if a hash exists")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasHas(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("refs")
|
||||||
|
.description("List direct CAS references from a node")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasRefs(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("walk")
|
||||||
|
.description("Recursive traversal from a node")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasWalk(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const casSchema = cas.command("schema").description("CAS schema operations");
|
||||||
|
|
||||||
|
casSchema
|
||||||
|
.command("list")
|
||||||
|
.description("List all registered schemas")
|
||||||
|
.action(() => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasSchemaList(storageRoot));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
casSchema
|
||||||
|
.command("get")
|
||||||
|
.description("Show a schema by its type hash")
|
||||||
|
.argument("<hash>", "Schema type hash")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasSchemaGet(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parseAsync(process.argv).catch((e: unknown) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
|
||||||
|
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
function openStore(storageRoot: string): Store {
|
||||||
|
return createFsStore(join(storageRoot, "cas"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonArg(fileOrInline: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fileOrInline);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(fileOrInline, "utf-8"));
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Cannot parse JSON from "${fileOrInline}": ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Commands (all return JSON-serializable data) ----
|
||||||
|
|
||||||
|
export async function cmdCasGet(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const store = openStore(storageRoot);
|
||||||
|
const node = store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
throw new Error(`Node not found: ${hash}`);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdCasCat(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
opts: { payload?: boolean },
|
||||||
|
): Promise<unknown> {
|
||||||
|
const store = openStore(storageRoot);
|
||||||
|
const node = store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
throw new Error(`Node not found: ${hash}`);
|
||||||
|
}
|
||||||
|
return opts.payload ? node.payload : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdCasPut(
|
||||||
|
storageRoot: string,
|
||||||
|
typeHash: string,
|
||||||
|
data: string,
|
||||||
|
): Promise<{ hash: string }> {
|
||||||
|
const store = openStore(storageRoot);
|
||||||
|
const payload = readJsonArg(data);
|
||||||
|
const hash = await store.put(typeHash, payload);
|
||||||
|
return { hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdCasHas(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<{ exists: boolean }> {
|
||||||
|
const store = openStore(storageRoot);
|
||||||
|
return { exists: store.has(hash) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdCasRefs(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<{ refs: string[] }> {
|
||||||
|
const store = openStore(storageRoot);
|
||||||
|
const node = store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
throw new Error(`Node not found: ${hash}`);
|
||||||
|
}
|
||||||
|
return { refs: refs(store, node) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdCasWalk(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<{ hashes: string[] }> {
|
||||||
|
const store = openStore(storageRoot);
|
||||||
|
const result: string[] = [];
|
||||||
|
walk(store, hash, (h) => {
|
||||||
|
result.push(h);
|
||||||
|
});
|
||||||
|
return { hashes: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SchemaListEntry = {
|
||||||
|
hash: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function cmdCasSchemaList(
|
||||||
|
storageRoot: string,
|
||||||
|
): Promise<SchemaListEntry[]> {
|
||||||
|
const store = openStore(storageRoot);
|
||||||
|
const metaHash = await bootstrap(store);
|
||||||
|
const entries: SchemaListEntry[] = [];
|
||||||
|
|
||||||
|
// Include meta-schema itself
|
||||||
|
entries.push({ hash: metaHash, title: "(meta-schema)" });
|
||||||
|
|
||||||
|
for (const hash of store.list()) {
|
||||||
|
if (hash === metaHash) continue;
|
||||||
|
const node = store.get(hash);
|
||||||
|
if (node !== null && node.type === metaHash) {
|
||||||
|
const schema = node.payload as JSONSchema;
|
||||||
|
const title =
|
||||||
|
(schema.title as string | undefined) ??
|
||||||
|
(schema.description as string | undefined) ??
|
||||||
|
"(unnamed)";
|
||||||
|
entries.push({ hash, title });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdCasSchemaGet(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const store = openStore(storageRoot);
|
||||||
|
const schema = getSchema(store, hash);
|
||||||
|
if (schema === null) {
|
||||||
|
throw new Error(`Schema not found: ${hash}`);
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
|
import { stdin as input, stdout as output } from "node:process";
|
||||||
|
|
||||||
|
import { stringify, parse } from "yaml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset provider list — embedded to avoid runtime YAML loading dependency.
|
||||||
|
* Keep in sync with providers.yaml in cli-workflow.
|
||||||
|
*/
|
||||||
|
const PRESET_PROVIDERS = [
|
||||||
|
// 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: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
|
||||||
|
{ name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1" },
|
||||||
|
// Local
|
||||||
|
{ name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SetupArgs = {
|
||||||
|
provider: string;
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
agent?: string | undefined;
|
||||||
|
storageRoot: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getConfigPath(root: string): string {
|
||||||
|
return join(root, "config.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvPath(root: string): string {
|
||||||
|
return join(root, ".env");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing config.yaml or return empty structure.
|
||||||
|
*/
|
||||||
|
function loadExistingConfig(configPath: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
const raw = parse(readFileSync(configPath, "utf8")) as unknown;
|
||||||
|
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
||||||
|
return raw as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors, start fresh
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing .env as key=value map.
|
||||||
|
*/
|
||||||
|
function loadEnvFile(envPath: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq > 0) {
|
||||||
|
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEnvFile(envPath: string, env: Record<string, string>): void {
|
||||||
|
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
||||||
|
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiKeyEnvName(providerName: string): string {
|
||||||
|
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||||
|
*/
|
||||||
|
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
|
||||||
|
const providers = (typeof existing.providers === "object" && existing.providers !== null
|
||||||
|
? { ...(existing.providers as Record<string, unknown>) }
|
||||||
|
: {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const envName = apiKeyEnvName(args.provider);
|
||||||
|
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
|
||||||
|
|
||||||
|
const models = (typeof existing.models === "object" && existing.models !== null
|
||||||
|
? { ...(existing.models as Record<string, unknown>) }
|
||||||
|
: {}) as Record<string, unknown>;
|
||||||
|
models.default = { provider: args.provider, name: args.model };
|
||||||
|
|
||||||
|
const agents = (typeof existing.agents === "object" && existing.agents !== null
|
||||||
|
? { ...(existing.agents as Record<string, unknown>) }
|
||||||
|
: {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const agentName = args.agent ?? "hermes";
|
||||||
|
if (Object.keys(agents).length === 0) {
|
||||||
|
agents.hermes = { command: "uwf-hermes", args: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
providers,
|
||||||
|
models,
|
||||||
|
agents,
|
||||||
|
defaultAgent: existing.defaultAgent ?? agentName,
|
||||||
|
defaultModel: existing.defaultModel ?? "default",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-interactive setup. All required args provided via CLI flags.
|
||||||
|
*/
|
||||||
|
export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>> {
|
||||||
|
const { storageRoot } = args;
|
||||||
|
mkdirSync(storageRoot, { recursive: true });
|
||||||
|
|
||||||
|
const configPath = getConfigPath(storageRoot);
|
||||||
|
const envPath = getEnvPath(storageRoot);
|
||||||
|
|
||||||
|
const existing = loadExistingConfig(configPath);
|
||||||
|
const merged = mergeConfig(existing, args);
|
||||||
|
|
||||||
|
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
|
||||||
|
|
||||||
|
// Write API key to .env
|
||||||
|
const envName = apiKeyEnvName(args.provider);
|
||||||
|
const envData = loadEnvFile(envPath);
|
||||||
|
envData[envName] = args.apiKey;
|
||||||
|
saveEnvFile(envPath, envData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configPath,
|
||||||
|
envPath,
|
||||||
|
provider: args.provider,
|
||||||
|
model: args.model,
|
||||||
|
defaultAgent: merged.defaultAgent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a line with terminal echo disabled (for secrets). */
|
||||||
|
async function promptSecret(label: string): Promise<string> {
|
||||||
|
process.stdout.write(label);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rawWasSet = process.stdin.isRaw;
|
||||||
|
if (process.stdin.isTTY) {
|
||||||
|
process.stdin.setRawMode(true);
|
||||||
|
}
|
||||||
|
process.stdin.resume();
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
|
||||||
|
let buf = "";
|
||||||
|
const onData = (chunk: string) => {
|
||||||
|
for (const c of chunk.toString()) {
|
||||||
|
if (c === "\n" || c === "\r" || c === "\u0004") {
|
||||||
|
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdin.removeListener("data", onData);
|
||||||
|
process.stdout.write("\n");
|
||||||
|
resolve(buf.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (c === "\u007F" || c === "\b") {
|
||||||
|
if (buf.length > 0) {
|
||||||
|
buf = buf.slice(0, -1);
|
||||||
|
process.stdout.write("\b \b");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "\u0003") {
|
||||||
|
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||||
|
process.exit(130);
|
||||||
|
}
|
||||||
|
buf += c;
|
||||||
|
process.stdout.write("*");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.stdin.on("data", onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch available models from an OpenAI-compatible /models endpoint. */
|
||||||
|
async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const body = (await res.json()) as { data?: { id: string }[] };
|
||||||
|
if (!Array.isArray(body.data)) return [];
|
||||||
|
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
|
||||||
|
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive setup — prompts user for provider, API key, model.
|
||||||
|
*/
|
||||||
|
export async function cmdSetupInteractive(storageRoot: string): Promise<Record<string, unknown>> {
|
||||||
|
const rl = createInterface({ input, output });
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Configure LLM provider for uwf workflow agents.\n");
|
||||||
|
|
||||||
|
// 1. Provider selection
|
||||||
|
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
|
||||||
|
console.log("Select a provider:\n");
|
||||||
|
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
|
||||||
|
const p = PRESET_PROVIDERS[i];
|
||||||
|
if (!p) continue;
|
||||||
|
const num = String(i + 1).padStart(numWidth);
|
||||||
|
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||||
|
}
|
||||||
|
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
|
||||||
|
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||||
|
|
||||||
|
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||||
|
const choiceNum = Number.parseInt(choice, 10);
|
||||||
|
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||||
|
throw new Error(`Invalid choice: ${choice}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let providerName: string;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
if (choiceNum <= PRESET_PROVIDERS.length) {
|
||||||
|
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||||
|
if (!selected) throw new Error("Invalid selection");
|
||||||
|
providerName = selected.name;
|
||||||
|
baseUrl = selected.baseUrl;
|
||||||
|
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||||
|
} else {
|
||||||
|
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||||
|
if (!providerName) throw new Error("Provider name required");
|
||||||
|
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||||
|
if (!baseUrl) throw new Error("Base URL required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. API key
|
||||||
|
rl.close();
|
||||||
|
const apiKey = await promptSecret("API key: ");
|
||||||
|
if (!apiKey) throw new Error("API key required");
|
||||||
|
|
||||||
|
// 3. Model selection
|
||||||
|
const rl2 = createInterface({ input, output });
|
||||||
|
console.log("\nFetching available models...");
|
||||||
|
const models = await fetchModels(baseUrl, apiKey);
|
||||||
|
|
||||||
|
let model: string;
|
||||||
|
if (models.length > 0) {
|
||||||
|
console.log(`\nAvailable models (${models.length}):\n`);
|
||||||
|
const nw = String(models.length).length;
|
||||||
|
// Multi-column layout
|
||||||
|
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||||
|
const colWidth = nw + 2 + maxLen + 4; // " N) name "
|
||||||
|
const termCols = process.stdout.columns || 100;
|
||||||
|
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||||
|
const rows = Math.ceil(models.length / cols);
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
let line = "";
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const idx = c * rows + r;
|
||||||
|
if (idx >= models.length) break;
|
||||||
|
const num = String(idx + 1).padStart(nw);
|
||||||
|
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||||
|
line += ` ${num}) ${name} `;
|
||||||
|
}
|
||||||
|
console.log(line.trimEnd());
|
||||||
|
}
|
||||||
|
console.log(`\nChoose a number, or type a model name directly.`);
|
||||||
|
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
|
||||||
|
if (!modelInput) throw new Error("Model required");
|
||||||
|
const modelNum = Number.parseInt(modelInput, 10);
|
||||||
|
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||||
|
model = models[modelNum - 1] ?? modelInput;
|
||||||
|
} else {
|
||||||
|
model = modelInput;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Could not fetch models. Enter model name manually.");
|
||||||
|
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||||
|
if (!model) throw new Error("Model required");
|
||||||
|
}
|
||||||
|
|
||||||
|
rl2.close();
|
||||||
|
|
||||||
|
console.log(` → ${providerName}/${model}\n`);
|
||||||
|
|
||||||
|
await cmdSetup({
|
||||||
|
provider: providerName,
|
||||||
|
baseUrl,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
storageRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Setup complete! Get started:\n");
|
||||||
|
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||||
|
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||||
|
console.log(" uwf thread step <thread-id> Execute next step");
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
return null as unknown as Record<string, unknown>;
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
import { validate } from "@uncaged/json-cas";
|
||||||
|
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||||
|
import { evaluate } from "@uncaged/uwf-moderator";
|
||||||
|
import type {
|
||||||
|
AgentAlias,
|
||||||
|
AgentConfig,
|
||||||
|
CasRef,
|
||||||
|
ModeratorContext,
|
||||||
|
StartNodePayload,
|
||||||
|
StartOutput,
|
||||||
|
StepContext,
|
||||||
|
StepNodePayload,
|
||||||
|
StepOutput,
|
||||||
|
ThreadId,
|
||||||
|
ThreadListItem,
|
||||||
|
WorkflowConfig,
|
||||||
|
WorkflowPayload,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
import { generateUlid } from "@uncaged/workflow-util";
|
||||||
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendThreadHistory,
|
||||||
|
createUwfStore,
|
||||||
|
findThreadInHistory,
|
||||||
|
loadThreadHistory,
|
||||||
|
loadThreadsIndex,
|
||||||
|
loadWorkflowRegistry,
|
||||||
|
resolveWorkflowHash,
|
||||||
|
saveThreadsIndex,
|
||||||
|
type ThreadHistoryLine,
|
||||||
|
type UwfStore,
|
||||||
|
} from "../store.js";
|
||||||
|
import { isCasRef } from "../validate.js";
|
||||||
|
|
||||||
|
const END_ROLE = "$END";
|
||||||
|
|
||||||
|
type ChainState = {
|
||||||
|
startHash: CasRef;
|
||||||
|
start: StartNodePayload;
|
||||||
|
stepsNewestFirst: StepNodePayload[];
|
||||||
|
headIsStart: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KillOutput = {
|
||||||
|
thread: ThreadId;
|
||||||
|
archived: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWorkflowCasRef(
|
||||||
|
uwf: UwfStore,
|
||||||
|
storageRoot: string,
|
||||||
|
workflowId: string,
|
||||||
|
): Promise<CasRef> {
|
||||||
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
const hash = resolveWorkflowHash(registry, workflowId);
|
||||||
|
if (!isCasRef(hash)) {
|
||||||
|
fail(`workflow not found: ${workflowId}`);
|
||||||
|
}
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.workflow) {
|
||||||
|
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkflowFromHead(uwf: UwfStore, head: CasRef): CasRef | null {
|
||||||
|
const node = uwf.store.get(head);
|
||||||
|
if (node === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === uwf.schemas.startNode) {
|
||||||
|
const payload = node.payload as StartNodePayload;
|
||||||
|
return payload.workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
if (typeof payload.start !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = uwf.store.get(payload.start);
|
||||||
|
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (startNode.payload as StartNodePayload).workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadStart(
|
||||||
|
storageRoot: string,
|
||||||
|
workflowId: string,
|
||||||
|
prompt: string,
|
||||||
|
): Promise<StartOutput> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
|
||||||
|
|
||||||
|
const threadId = generateUlid(Date.now()) as ThreadId;
|
||||||
|
const startPayload: StartNodePayload = {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||||
|
const node = uwf.store.get(headHash);
|
||||||
|
if (node === null || !validate(uwf.store, node)) {
|
||||||
|
fail("stored StartNode failed schema validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
index[threadId] = headHash;
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
|
||||||
|
return { workflow: workflowHash, thread: threadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const activeHead = index[threadId];
|
||||||
|
if (activeHead !== undefined) {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const workflow = resolveWorkflowFromHead(uwf, activeHead);
|
||||||
|
if (workflow === null) {
|
||||||
|
fail(`failed to resolve workflow from head: ${activeHead}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workflow,
|
||||||
|
thread: threadId,
|
||||||
|
head: activeHead,
|
||||||
|
done: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||||
|
if (hist !== null) {
|
||||||
|
return {
|
||||||
|
workflow: hist.workflow,
|
||||||
|
thread: threadId,
|
||||||
|
head: hist.head,
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(`thread not found: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function threadListItemFromActive(
|
||||||
|
uwf: UwfStore,
|
||||||
|
threadId: ThreadId,
|
||||||
|
head: CasRef,
|
||||||
|
): Promise<ThreadListItem | null> {
|
||||||
|
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||||
|
if (workflow === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { thread: threadId, workflow, head };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadList(
|
||||||
|
storageRoot: string,
|
||||||
|
includeAll: boolean,
|
||||||
|
): Promise<ThreadListItem[]> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const items: ThreadListItem[] = [];
|
||||||
|
|
||||||
|
for (const [threadId, head] of Object.entries(index)) {
|
||||||
|
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
|
||||||
|
if (item !== null) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!includeAll) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIds = new Set(items.map((i) => i.thread));
|
||||||
|
const history = await loadThreadHistory(storageRoot);
|
||||||
|
for (const entry of history) {
|
||||||
|
if (!activeIds.has(entry.thread)) {
|
||||||
|
items.push({
|
||||||
|
thread: entry.thread,
|
||||||
|
workflow: entry.workflow,
|
||||||
|
head: entry.head,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
|
||||||
|
const headNode = uwf.store.get(headHash);
|
||||||
|
if (headNode === null) {
|
||||||
|
fail(`CAS node not found: ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type === uwf.schemas.startNode) {
|
||||||
|
return {
|
||||||
|
startHash: headHash,
|
||||||
|
start: headNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst: [],
|
||||||
|
headIsStart: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type !== uwf.schemas.stepNode) {
|
||||||
|
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsNewestFirst: StepNodePayload[] = [];
|
||||||
|
let hash: CasRef | null = headHash;
|
||||||
|
|
||||||
|
while (hash !== null) {
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found while walking chain: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.stepNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
stepsNewestFirst.push(payload);
|
||||||
|
hash = payload.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newest = stepsNewestFirst[0];
|
||||||
|
if (newest === undefined) {
|
||||||
|
fail(`empty step chain at head ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = uwf.store.get(newest.start);
|
||||||
|
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||||
|
fail(`StartNode not found: ${newest.start}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startHash: newest.start,
|
||||||
|
start: startNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst,
|
||||||
|
headIsStart: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
||||||
|
const node = uwf.store.get(outputRef);
|
||||||
|
if (node === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return node.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||||
|
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||||
|
const steps: StepContext[] = chronological.map((step) => ({
|
||||||
|
role: step.role,
|
||||||
|
output: expandOutput(uwf, step.output),
|
||||||
|
detail: step.detail,
|
||||||
|
agent: step.agent,
|
||||||
|
}));
|
||||||
|
return { start: chain.start, steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||||
|
const node = uwf.store.get(workflowRef);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.workflow) {
|
||||||
|
fail(`node ${workflowRef} is not a Workflow`);
|
||||||
|
}
|
||||||
|
return node.payload as WorkflowPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgentOverride(override: string): AgentConfig {
|
||||||
|
const parts = override
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
|
const command = parts[0];
|
||||||
|
if (command === undefined) {
|
||||||
|
fail("agent override must not be empty");
|
||||||
|
}
|
||||||
|
return { command, args: parts.slice(1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentConfig(
|
||||||
|
config: WorkflowConfig,
|
||||||
|
workflow: WorkflowPayload,
|
||||||
|
role: string,
|
||||||
|
agentOverride: string | null,
|
||||||
|
): AgentConfig {
|
||||||
|
if (agentOverride !== null) {
|
||||||
|
return parseAgentOverride(agentOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alias: AgentAlias = config.defaultAgent;
|
||||||
|
if (config.agentOverrides !== null) {
|
||||||
|
const roleOverrides = config.agentOverrides[workflow.name];
|
||||||
|
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
|
||||||
|
alias = roleOverrides[role];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentConfig = config.agents[alias];
|
||||||
|
if (agentConfig === undefined) {
|
||||||
|
fail(`unknown agent alias in config: ${alias}`);
|
||||||
|
}
|
||||||
|
return agentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
||||||
|
const argv = [...agent.args, threadId, role];
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
stdout = execFileSync(agent.command, argv, {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: process.env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
|
||||||
|
const stderr =
|
||||||
|
err.stderr === undefined
|
||||||
|
? ""
|
||||||
|
: typeof err.stderr === "string"
|
||||||
|
? err.stderr
|
||||||
|
: err.stderr.toString("utf8");
|
||||||
|
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
|
||||||
|
fail(`agent command failed (${agent.command})${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||||
|
if (!isCasRef(line)) {
|
||||||
|
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveThread(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
workflow: CasRef,
|
||||||
|
head: CasRef,
|
||||||
|
): Promise<void> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
delete index[threadId];
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
await appendThreadHistory(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow,
|
||||||
|
head,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadStep(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
agentOverride: string | null,
|
||||||
|
): Promise<StepOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const headHash = index[threadId];
|
||||||
|
if (headHash === undefined) {
|
||||||
|
fail(`thread not active: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const chain = walkChain(uwf, headHash);
|
||||||
|
const workflowHash = chain.start.workflow;
|
||||||
|
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||||
|
const context = buildModeratorContext(uwf, chain);
|
||||||
|
|
||||||
|
const nextResult = await evaluate(workflow, context);
|
||||||
|
if (!nextResult.ok) {
|
||||||
|
fail(nextResult.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextResult.value === END_ROLE) {
|
||||||
|
await archiveThread(storageRoot, threadId, workflowHash, headHash);
|
||||||
|
return {
|
||||||
|
workflow: workflowHash,
|
||||||
|
thread: threadId,
|
||||||
|
head: headHash,
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = nextResult.value;
|
||||||
|
const config = await loadWorkflowConfig(storageRoot);
|
||||||
|
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||||
|
|
||||||
|
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||||
|
const newHead = spawnAgent(agent, threadId, role);
|
||||||
|
|
||||||
|
// Re-create store to pick up nodes written by the agent subprocess
|
||||||
|
const uwfAfter = await createUwfStore(storageRoot);
|
||||||
|
const newNode = uwfAfter.store.get(newHead);
|
||||||
|
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
|
||||||
|
fail(`agent returned hash that is not a StepNode: ${newHead}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload threads index to avoid overwriting changes made by the agent subprocess
|
||||||
|
const freshIndex = await loadThreadsIndex(storageRoot);
|
||||||
|
freshIndex[threadId] = newHead;
|
||||||
|
await saveThreadsIndex(storageRoot, freshIndex);
|
||||||
|
|
||||||
|
const chainAfter = walkChain(uwfAfter, newHead);
|
||||||
|
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
||||||
|
const afterResult = await evaluate(workflow, contextAfter);
|
||||||
|
if (!afterResult.ok) {
|
||||||
|
fail(afterResult.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = afterResult.value === END_ROLE;
|
||||||
|
if (done) {
|
||||||
|
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflow: workflowHash,
|
||||||
|
thread: threadId,
|
||||||
|
head: newHead,
|
||||||
|
done,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const head = index[threadId];
|
||||||
|
if (head === undefined) {
|
||||||
|
fail(`thread not active: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||||
|
if (workflow === null) {
|
||||||
|
fail(`failed to resolve workflow from head: ${head}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete index[threadId];
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
|
||||||
|
const historyEntry: ThreadHistoryLine = {
|
||||||
|
thread: threadId,
|
||||||
|
workflow,
|
||||||
|
head,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await appendThreadHistory(storageRoot, historyEntry);
|
||||||
|
|
||||||
|
return { thread: threadId, archived: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
|
import { putSchema, validate } from "@uncaged/json-cas";
|
||||||
|
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createUwfStore,
|
||||||
|
findRegistryName,
|
||||||
|
loadWorkflowRegistry,
|
||||||
|
resolveWorkflowHash,
|
||||||
|
saveWorkflowRegistry,
|
||||||
|
type UwfStore,
|
||||||
|
} from "../store.js";
|
||||||
|
import { parseWorkflowPayload } from "../validate.js";
|
||||||
|
|
||||||
|
export type WorkflowListEntry = {
|
||||||
|
name: string;
|
||||||
|
hash: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowPutOutput = {
|
||||||
|
name: string;
|
||||||
|
hash: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowShowOutput = {
|
||||||
|
hash: CasRef;
|
||||||
|
name: string | null;
|
||||||
|
type: CasRef;
|
||||||
|
payload: WorkflowPayload;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonSchema(value: unknown): value is JSONSchema {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOutputSchemaRef(
|
||||||
|
uwf: UwfStore,
|
||||||
|
roleName: string,
|
||||||
|
outputSchema: unknown,
|
||||||
|
): Promise<CasRef> {
|
||||||
|
if (!isJsonSchema(outputSchema)) {
|
||||||
|
fail(`role "${roleName}": outputSchema must be a JSON Schema object`);
|
||||||
|
}
|
||||||
|
const schema: JSONSchema = outputSchema.title === undefined
|
||||||
|
? { ...outputSchema, title: roleName }
|
||||||
|
: outputSchema;
|
||||||
|
return putSchema(uwf.store, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function materializeWorkflowPayload(
|
||||||
|
uwf: UwfStore,
|
||||||
|
raw: WorkflowPayload,
|
||||||
|
): Promise<WorkflowPayload> {
|
||||||
|
const roles: Record<string, RoleDefinition> = {};
|
||||||
|
for (const [roleName, role] of Object.entries(raw.roles)) {
|
||||||
|
const outputSchema = await resolveOutputSchemaRef(
|
||||||
|
uwf,
|
||||||
|
`${raw.name}.${roleName}`,
|
||||||
|
role.outputSchema,
|
||||||
|
);
|
||||||
|
roles[roleName] = {
|
||||||
|
description: role.description,
|
||||||
|
systemPrompt: role.systemPrompt,
|
||||||
|
outputSchema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: raw.name,
|
||||||
|
description: raw.description,
|
||||||
|
roles,
|
||||||
|
conditions: raw.conditions,
|
||||||
|
graph: raw.graph,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdWorkflowPut(
|
||||||
|
storageRoot: string,
|
||||||
|
filePath: string,
|
||||||
|
): Promise<WorkflowPutOutput> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(filePath, "utf8");
|
||||||
|
} catch {
|
||||||
|
fail(`file not found: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = parse(text) as unknown;
|
||||||
|
} catch (e) {
|
||||||
|
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseWorkflowPayload(raw);
|
||||||
|
if (payload === null) {
|
||||||
|
fail("invalid workflow YAML: expected WorkflowPayload shape");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||||
|
|
||||||
|
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null || !validate(uwf.store, node)) {
|
||||||
|
fail("stored workflow failed schema validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
registry[materialized.name] = hash;
|
||||||
|
await saveWorkflowRegistry(storageRoot, registry);
|
||||||
|
|
||||||
|
return { name: materialized.name, hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdWorkflowShow(
|
||||||
|
storageRoot: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<WorkflowShowOutput> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
const hash = resolveWorkflowHash(registry, id);
|
||||||
|
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.workflow) {
|
||||||
|
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = node.payload as WorkflowPayload;
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
name: findRegistryName(registry, hash),
|
||||||
|
type: node.type,
|
||||||
|
payload,
|
||||||
|
timestamp: node.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
|
||||||
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { stringify } from "yaml";
|
||||||
|
|
||||||
|
export type OutputFormat = "json" | "yaml";
|
||||||
|
|
||||||
|
export function formatOutput(data: unknown, format: OutputFormat): string {
|
||||||
|
switch (format) {
|
||||||
|
case "json":
|
||||||
|
return JSON.stringify(data);
|
||||||
|
case "yaml":
|
||||||
|
return stringify(data).trimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Hash, Store } from "@uncaged/json-cas";
|
||||||
|
import { putSchema } from "@uncaged/json-cas";
|
||||||
|
import {
|
||||||
|
START_NODE_SCHEMA,
|
||||||
|
STEP_NODE_SCHEMA,
|
||||||
|
WORKFLOW_SCHEMA,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
export type UwfSchemaHashes = {
|
||||||
|
workflow: Hash;
|
||||||
|
startNode: Hash;
|
||||||
|
stepNode: Hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
|
||||||
|
* Idempotent: safe to call on every CLI invocation.
|
||||||
|
*/
|
||||||
|
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
|
||||||
|
const [workflow, startNode, stepNode] = await Promise.all([
|
||||||
|
putSchema(store, WORKFLOW_SCHEMA),
|
||||||
|
putSchema(store, START_NODE_SCHEMA),
|
||||||
|
putSchema(store, STEP_NODE_SCHEMA),
|
||||||
|
]);
|
||||||
|
return { workflow, startNode, stepNode };
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { Hash, Store } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
|
||||||
|
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
|
||||||
|
|
||||||
|
export type WorkflowRegistry = Record<string, CasRef>;
|
||||||
|
|
||||||
|
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||||
|
export function getDefaultStorageRoot(): string {
|
||||||
|
return join(homedir(), ".uncaged", "workflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve storage root.
|
||||||
|
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
|
||||||
|
*/
|
||||||
|
export function resolveStorageRoot(): string {
|
||||||
|
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (internal !== undefined && internal !== "") {
|
||||||
|
return internal;
|
||||||
|
}
|
||||||
|
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (userOverride !== undefined && userOverride !== "") {
|
||||||
|
return userOverride;
|
||||||
|
}
|
||||||
|
return getDefaultStorageRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCasDir(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "cas");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegistryPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "workflows.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThreadsPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "threads.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistoryPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "history.jsonl");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadHistoryLine = ThreadListItem & {
|
||||||
|
completedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UwfStore = {
|
||||||
|
storageRoot: string;
|
||||||
|
store: Store;
|
||||||
|
schemas: UwfSchemaHashes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||||
|
const casDir = getCasDir(storageRoot);
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
return { storageRoot, store, schemas };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWorkflowRegistry(storageRoot: string): Promise<WorkflowRegistry> {
|
||||||
|
const path = getRegistryPath(storageRoot);
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = parse(text) as unknown;
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const registry: WorkflowRegistry = {};
|
||||||
|
for (const [name, hash] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (typeof hash === "string") {
|
||||||
|
registry[name] = hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWorkflowRegistry(
|
||||||
|
storageRoot: string,
|
||||||
|
registry: WorkflowRegistry,
|
||||||
|
): Promise<void> {
|
||||||
|
const path = getRegistryPath(storageRoot);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
const text = stringify(registry, { indent: 2 });
|
||||||
|
await writeFile(path, text, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): CasRef {
|
||||||
|
return registry[id] !== undefined ? registry[id] : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
|
||||||
|
for (const [name, h] of Object.entries(registry)) {
|
||||||
|
if (h === hash) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
|
||||||
|
const path = getThreadsPath(storageRoot);
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = parse(text) as unknown;
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const index: ThreadsIndex = {};
|
||||||
|
for (const [threadId, head] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (typeof head === "string") {
|
||||||
|
index[threadId as ThreadId] = head;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveThreadsIndex(storageRoot: string, index: ThreadsIndex): Promise<void> {
|
||||||
|
const path = getThreadsPath(storageRoot);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
const text = stringify(index, { indent: 2 });
|
||||||
|
await writeFile(path, text, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadThreadHistory(storageRoot: string): Promise<ThreadHistoryLine[]> {
|
||||||
|
const path = getHistoryPath(storageRoot);
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const lines: ThreadHistoryLine[] = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rec = raw as Record<string, unknown>;
|
||||||
|
const thread = rec.thread;
|
||||||
|
const workflow = rec.workflow;
|
||||||
|
const head = rec.head;
|
||||||
|
const completedAt = rec.completedAt;
|
||||||
|
if (
|
||||||
|
typeof thread === "string" &&
|
||||||
|
typeof workflow === "string" &&
|
||||||
|
typeof head === "string" &&
|
||||||
|
typeof completedAt === "number"
|
||||||
|
) {
|
||||||
|
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findThreadInHistory(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
): Promise<ThreadHistoryLine | null> {
|
||||||
|
const history = await loadThreadHistory(storageRoot);
|
||||||
|
for (let i = history.length - 1; i >= 0; i--) {
|
||||||
|
const entry = history[i];
|
||||||
|
if (entry !== undefined && entry.thread === threadId) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendThreadHistory(
|
||||||
|
storageRoot: string,
|
||||||
|
entry: ThreadHistoryLine,
|
||||||
|
): Promise<void> {
|
||||||
|
const path = getHistoryPath(storageRoot);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
const line = `${JSON.stringify(entry)}\n`;
|
||||||
|
await appendFile(path, line, "utf8");
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { CasRef, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||||
|
|
||||||
|
export function isCasRef(value: string): value is CasRef {
|
||||||
|
return CAS_REF_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRoleDefinition(value: unknown): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const outputSchema = value.outputSchema;
|
||||||
|
const schemaOk = isRecord(outputSchema) && typeof outputSchema.type === "string";
|
||||||
|
return (
|
||||||
|
typeof value.description === "string" && typeof value.systemPrompt === "string" && schemaOk
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConditionDefinition(value: unknown): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return typeof value.description === "string" && typeof value.expression === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTransition(value: unknown): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const condition = value.condition;
|
||||||
|
return typeof value.role === "string" && (condition === null || typeof condition === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Object.values(value).every(itemCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGraph(value: unknown): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Object.values(value).every(
|
||||||
|
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
|
||||||
|
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isStringRecord(raw.roles, isRoleDefinition) ||
|
||||||
|
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
||||||
|
!isGraph(raw.graph)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw as WorkflowPayload;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../uwf-protocol" },
|
||||||
|
{ "path": "../uwf-moderator" },
|
||||||
|
{ "path": "../uwf-agent-kit" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# @uncaged/cli-workflow
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.0
|
||||||
|
|
||||||
|
## 0.4.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.4.5
|
||||||
|
- @uncaged/workflow-cas@0.4.5
|
||||||
|
- @uncaged/workflow-execute@0.4.5
|
||||||
|
- @uncaged/workflow-gateway@0.4.5
|
||||||
|
- @uncaged/workflow-register@0.4.5
|
||||||
|
- @uncaged/workflow-runtime@0.4.5
|
||||||
|
- @uncaged/workflow-util@0.4.5
|
||||||
|
|
||||||
|
## 0.4.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.4.4
|
||||||
|
- @uncaged/workflow-cas@0.4.4
|
||||||
|
- @uncaged/workflow-execute@0.4.4
|
||||||
|
- @uncaged/workflow-gateway@0.4.4
|
||||||
|
- @uncaged/workflow-register@0.4.4
|
||||||
|
- @uncaged/workflow-runtime@0.4.4
|
||||||
|
- @uncaged/workflow-util@0.4.4
|
||||||
|
|
||||||
|
## 0.4.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-cas@0.4.3
|
||||||
|
- @uncaged/workflow-execute@0.4.3
|
||||||
|
- @uncaged/workflow-gateway@0.4.3
|
||||||
|
- @uncaged/workflow-protocol@0.4.3
|
||||||
|
- @uncaged/workflow-register@0.4.3
|
||||||
|
- @uncaged/workflow-runtime@0.4.3
|
||||||
|
- @uncaged/workflow-util@0.4.3
|
||||||
|
|
||||||
|
## 0.4.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-cas@0.4.2
|
||||||
|
- @uncaged/workflow-execute@0.4.2
|
||||||
|
- @uncaged/workflow-gateway@0.4.2
|
||||||
|
- @uncaged/workflow-protocol@0.4.2
|
||||||
|
- @uncaged/workflow-register@0.4.2
|
||||||
|
- @uncaged/workflow-runtime@0.4.2
|
||||||
|
- @uncaged/workflow-util@0.4.2
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Fix package exports for published packages and adopt changesets for version management.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-cas@0.4.0
|
||||||
|
- @uncaged/workflow-execute@0.4.0
|
||||||
|
- @uncaged/workflow-gateway@0.4.0
|
||||||
|
- @uncaged/workflow-protocol@0.4.0
|
||||||
|
- @uncaged/workflow-register@0.4.0
|
||||||
|
- @uncaged/workflow-runtime@0.4.0
|
||||||
|
- @uncaged/workflow-util@0.4.0
|
||||||
@@ -20,9 +20,6 @@ import { addCliArgs } from "./bundle-fixture.js";
|
|||||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
|
||||||
`;
|
|
||||||
|
|
||||||
function casStoredForm(raw: string): string {
|
function casStoredForm(raw: string): string {
|
||||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
}
|
}
|
||||||
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
`${fixtureDescriptor}import fs from "node:fs";
|
||||||
|
|
||||||
export const run = async function* (input, options) {
|
export const run = async function* (input, options) {
|
||||||
fs.existsSync(".");
|
fs.existsSync(".");
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, input.prompt);
|
const h = await cas.put(input.prompt);
|
||||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
}
|
}
|
||||||
@@ -155,10 +152,9 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
|||||||
},
|
},
|
||||||
graph: { edges: [] },
|
graph: { edges: [] },
|
||||||
};
|
};
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (input, options) {
|
export const run = async function* (input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, input.prompt);
|
const h = await cas.put( input.prompt);
|
||||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "ok" };
|
return { returnCode: 0, summary: "ok" };
|
||||||
};
|
};
|
||||||
@@ -197,9 +193,9 @@ export const run = async function* (input, options) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -228,9 +224,9 @@ export const run = async function* (input, options) {
|
|||||||
const dtsPath = join(bundleDir, "types.d.ts");
|
const dtsPath = join(bundleDir, "types.d.ts");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -261,9 +257,9 @@ export const run = async function* (input, options) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -284,16 +280,16 @@ export const run = async function* (input, options) {
|
|||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "v1");
|
const h = await cas.put( "v1");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v1" };
|
return { returnCode: 0, summary: "v1" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "v2");
|
const h = await cas.put( "v2");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v2" };
|
return { returnCode: 0, summary: "v2" };
|
||||||
}
|
}
|
||||||
@@ -326,16 +322,16 @@ export const run = async function* (input, options) {
|
|||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "v1");
|
const h = await cas.put( "v1");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v1" };
|
return { returnCode: 0, summary: "v1" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "v2");
|
const h = await cas.put( "v2");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v2" };
|
return { returnCode: 0, summary: "v2" };
|
||||||
}
|
}
|
||||||
@@ -378,9 +374,9 @@ export const run = async function* (input, options) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -391,9 +387,9 @@ export const run = async function* (input, options) {
|
|||||||
expect(add1.ok).toBe(true);
|
expect(add1.ok).toBe(true);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "y");
|
const h = await cas.put( "y");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "y" };
|
return { returnCode: 0, summary: "y" };
|
||||||
}
|
}
|
||||||
@@ -446,9 +442,9 @@ export const run = async function* (input, options) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -463,9 +459,9 @@ export const run = async function* (input, options) {
|
|||||||
const hash1 = add1.value.hash;
|
const hash1 = add1.value.hash;
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "y");
|
const h = await cas.put( "y");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "y" };
|
return { returnCode: 0, summary: "y" };
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
|
|||||||
|
|
||||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||||
|
|
||||||
import { createApp } from "../src/commands/serve/app.js";
|
import { createApp } from "../src/commands/connect/app.js";
|
||||||
|
|
||||||
function casStoredForm(raw: string): string {
|
function casStoredForm(raw: string): string {
|
||||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApp(storageRoot: string) {
|
function buildApp(storageRoot: string) {
|
||||||
const app = createApp(storageRoot);
|
const app = createApp(storageRoot, null);
|
||||||
return {
|
return {
|
||||||
fetch: (path: string, init?: RequestInit) =>
|
fetch: (path: string, init?: RequestInit) =>
|
||||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||||
@@ -115,7 +115,7 @@ describe("serve error handling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("global error handler returns 500 with JSON", async () => {
|
test("global error handler returns 500 with JSON", async () => {
|
||||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||||
app.get("/test-error", () => {
|
app.get("/test-error", () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
});
|
});
|
||||||
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
|
|||||||
|
|
||||||
describe("serve security", () => {
|
describe("serve security", () => {
|
||||||
test("CORS headers present on responses", async () => {
|
test("CORS headers present on responses", async () => {
|
||||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||||
const res2 = await app.fetch(
|
const res2 = await app.fetch(
|
||||||
new Request("http://localhost/healthz", {
|
new Request("http://localhost/healthz", {
|
||||||
headers: { Origin: "http://localhost:5173" },
|
headers: { Origin: "http://localhost:5173" },
|
||||||
@@ -15,9 +15,7 @@ import { addCliArgs } from "./bundle-fixture.js";
|
|||||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||||
|
|
||||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
const threeRoleBundleSource = `export const descriptor = {
|
||||||
|
|
||||||
export const descriptor = {
|
|
||||||
description: "fork-cli",
|
description: "fork-cli",
|
||||||
roles: {
|
roles: {
|
||||||
planner: { description: "planner", schema: {} },
|
planner: { description: "planner", schema: {} },
|
||||||
@@ -30,16 +28,16 @@ export const run = async function* (input, options) {
|
|||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const has = (r) => input.steps.some((s) => s.role === r);
|
const has = (r) => input.steps.some((s) => s.role === r);
|
||||||
if (!has("planner")) {
|
if (!has("planner")) {
|
||||||
const h = await putContentMerkleNode(cas, "p1");
|
const h = await cas.put( "p1");
|
||||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||||
}
|
}
|
||||||
if (!has("coder")) {
|
if (!has("coder")) {
|
||||||
const h = await putContentMerkleNode(cas, "c1");
|
const h = await cas.put( "c1");
|
||||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||||
}
|
}
|
||||||
if (!has("reviewer")) {
|
if (!has("reviewer")) {
|
||||||
const body = "rev-" + String(input.steps.length);
|
const body = "rev-" + String(input.steps.length);
|
||||||
const h = await putContentMerkleNode(cas, body);
|
const h = await cas.put( body);
|
||||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||||
}
|
}
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
|
|||||||
import { addCliArgs } from "./bundle-fixture.js";
|
import { addCliArgs } from "./bundle-fixture.js";
|
||||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||||
|
|
||||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
|
||||||
`;
|
|
||||||
|
|
||||||
const threadFixtureDescriptor = `export const descriptor = {
|
const threadFixtureDescriptor = `export const descriptor = {
|
||||||
description: "thread-cli",
|
description: "thread-cli",
|
||||||
roles: {
|
roles: {
|
||||||
@@ -41,25 +38,23 @@ const threadFixtureDescriptor = `export const descriptor = {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const fastBundleSource = `${threadFixtureDescriptor}
|
const fastBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (input, options) {
|
export const run = async function* (input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
let h = await putContentMerkleNode(cas, "plan");
|
let h = await cas.put( "plan");
|
||||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||||
h = await putContentMerkleNode(cas, "code");
|
h = await cas.put( "code");
|
||||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (input, options) {
|
export const run = async function* (input, options) {
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
let h = await putContentMerkleNode(cas, "plan");
|
let h = await cas.put( "plan");
|
||||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||||
h = await putContentMerkleNode(cas, "code");
|
h = await cas.put( "code");
|
||||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
@@ -68,37 +63,34 @@ export const run = async function* (input, options) {
|
|||||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||||
|
|
||||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (input, options) {
|
export const run = async function* (input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
let h = await putContentMerkleNode(cas, "plan");
|
let h = await cas.put( "plan");
|
||||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||||
await new Promise((r) => setTimeout(r, 10000));
|
await new Promise((r) => setTimeout(r, 10000));
|
||||||
h = await putContentMerkleNode(cas, "code");
|
h = await cas.put( "code");
|
||||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (_input, options) {
|
export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
let h = await putContentMerkleNode(cas, "f");
|
let h = await cas.put( "f");
|
||||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
h = await putContentMerkleNode(cas, "s");
|
h = await cas.put( "s");
|
||||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (_input, options) {
|
export const run = async function* (_input, options) {
|
||||||
await new Promise((r) => setTimeout(r, 900));
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
@@ -180,6 +172,9 @@ describe("cli thread commands", () => {
|
|||||||
}
|
}
|
||||||
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
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);
|
const shown = await cmdThreadShow(storageRoot, threadId);
|
||||||
expect(shown.ok).toBe(true);
|
expect(shown.ok).toBe(true);
|
||||||
if (!shown.ok) {
|
if (!shown.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/cli-workflow",
|
"name": "@uncaged/cli-workflow",
|
||||||
"version": "0.3.21",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
@@ -11,17 +11,20 @@
|
|||||||
"uncaged-workflow": "src/cli.ts"
|
"uncaged-workflow": "src/cli.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-gateway": "workspace:*",
|
"@uncaged/workflow-gateway": "workspace:^",
|
||||||
"@uncaged/workflow-protocol": "workspace:*",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:*",
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
"@uncaged/workflow-cas": "workspace:*",
|
"@uncaged/workflow-cas": "workspace:^",
|
||||||
"@uncaged/workflow-execute": "workspace:*",
|
"@uncaged/workflow-execute": "workspace:^",
|
||||||
"@uncaged/workflow-register": "workspace:*",
|
"@uncaged/workflow-register": "workspace:^",
|
||||||
"@uncaged/workflow-runtime": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"hono": "^4.12.18",
|
"hono": "^4.12.18",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { printCliError, printCliLine } from "./cli-output.js";
|
|||||||
import { getCommandRegistry } from "./cli-registry.js";
|
import { getCommandRegistry } from "./cli-registry.js";
|
||||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||||
|
import { dispatchConnect } from "./commands/connect/index.js";
|
||||||
import { createInitDispatcher } from "./commands/init/index.js";
|
import { createInitDispatcher } from "./commands/init/index.js";
|
||||||
import { dispatchServe } from "./commands/serve/index.js";
|
|
||||||
import { dispatchSetup } from "./commands/setup/index.js";
|
import { dispatchSetup } from "./commands/setup/index.js";
|
||||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||||
@@ -71,7 +71,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
|||||||
skill: dispatchSkill,
|
skill: dispatchSkill,
|
||||||
run: dispatchRun,
|
run: dispatchRun,
|
||||||
live: dispatchLive,
|
live: dispatchLive,
|
||||||
serve: dispatchServe,
|
connect: dispatchConnect,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
|||||||
@@ -59,12 +59,12 @@ export function formatCliUsage(
|
|||||||
);
|
);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
lines.push("Server:");
|
lines.push("Gateway:");
|
||||||
lines.push(
|
lines.push(
|
||||||
...formatUsageCommandLines([
|
...formatUsageCommandLines([
|
||||||
{
|
{
|
||||||
prefix: "serve [--port N] [--host ADDR]",
|
prefix: "connect [--name NAME] [--gateway URL]",
|
||||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
description: "Connect to workflow gateway via WebSocket",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-5
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
|
|||||||
|
|
||||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||||
|
|
||||||
export function createApp(storageRoot: string, agentToken: string | null): Hono {
|
export function createApp(storageRoot: string, clientToken: string | null): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.onError((_err, c) => {
|
app.onError((_err, c) => {
|
||||||
@@ -37,11 +37,11 @@ export function createApp(storageRoot: string, agentToken: string | null): Hono
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Agent token auth (skip healthz) ───────────────────────────────
|
// ── Client token auth (skip healthz) ───────────────────────────────
|
||||||
if (agentToken !== null) {
|
if (clientToken !== null) {
|
||||||
app.use("/api/*", async (c, next) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
const token = c.req.header("X-Agent-Token");
|
const token = c.req.header("X-Client-Token");
|
||||||
if (token !== agentToken) {
|
if (token !== clientToken) {
|
||||||
return c.json({ error: "unauthorized" }, 401);
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { hostname as osHostname } from "node:os";
|
||||||
|
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
import { createLogger } from "@uncaged/workflow-util";
|
||||||
|
|
||||||
|
import { printCliLine } from "../../cli-output.js";
|
||||||
|
import { createApp } from "./app.js";
|
||||||
|
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
||||||
|
import type { ConnectOptions } from "./types.js";
|
||||||
|
import { startGatewayWsClient } from "./ws-client.js";
|
||||||
|
|
||||||
|
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined) {
|
||||||
|
return { ok: false, error: `${flag} requires a value` };
|
||||||
|
}
|
||||||
|
return ok(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
|
||||||
|
let name = osHostname().split(".")[0].toLowerCase();
|
||||||
|
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||||
|
const gatewaySecret = process.env.WORKFLOW_DASHBOARD_SECRET ?? "";
|
||||||
|
const stringFlags: Record<string, (v: string) => void> = {
|
||||||
|
"--name": (v) => {
|
||||||
|
name = v;
|
||||||
|
},
|
||||||
|
"--gateway": (v) => {
|
||||||
|
gatewayUrl = v;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg in stringFlags) {
|
||||||
|
const r = requireNextArg(argv, i, arg);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
stringFlags[arg](r.value);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ name, gatewayUrl, gatewaySecret });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchConnect(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const parsed = parseConnectArgv(argv);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
printCliLine(`error: ${parsed.error}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parsed.value;
|
||||||
|
|
||||||
|
if (options.gatewaySecret === "") {
|
||||||
|
printCliLine("error: WORKFLOW_DASHBOARD_SECRET is required");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientToken = randomUUID();
|
||||||
|
const app = createApp(storageRoot, clientToken);
|
||||||
|
|
||||||
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
const stopWsClient = startGatewayWsClient({
|
||||||
|
gatewayUrl: options.gatewayUrl,
|
||||||
|
name: options.name,
|
||||||
|
secret: options.gatewaySecret,
|
||||||
|
appFetch: app.fetch,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
|
printCliLine("connected to gateway via WebSocket");
|
||||||
|
|
||||||
|
// Register with gateway for discovery
|
||||||
|
const registered = await registerWithGateway(
|
||||||
|
options.gatewayUrl,
|
||||||
|
options.name,
|
||||||
|
`ws://${options.name}`,
|
||||||
|
options.gatewaySecret,
|
||||||
|
clientToken,
|
||||||
|
);
|
||||||
|
if (registered) {
|
||||||
|
printCliLine(`registered with gateway as "${options.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeatTimer = startHeartbeat(
|
||||||
|
options.gatewayUrl,
|
||||||
|
options.name,
|
||||||
|
`ws://${options.name}`,
|
||||||
|
options.gatewaySecret,
|
||||||
|
clientToken,
|
||||||
|
HEARTBEAT_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
clearInterval(heartbeatTimer);
|
||||||
|
stopWsClient();
|
||||||
|
printCliLine("unregistering from gateway...");
|
||||||
|
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", cleanup);
|
||||||
|
process.on("SIGTERM", cleanup);
|
||||||
|
|
||||||
|
await new Promise(() => {});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
+6
-40
@@ -1,51 +1,17 @@
|
|||||||
import { printCliLine } from "../../cli-output.js";
|
import { printCliLine } from "../../cli-output.js";
|
||||||
|
|
||||||
type TunnelHandle = {
|
|
||||||
process: ReturnType<typeof Bun.spawn>;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function startTunnel(port: number): Promise<TunnelHandle | null> {
|
|
||||||
const proc = Bun.spawn(["cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
// cloudflared prints the URL to stderr
|
|
||||||
const reader = proc.stderr.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
const deadline = Date.now() + 30_000;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
||||||
if (match) {
|
|
||||||
// Release the reader so stderr keeps flowing without backpressure
|
|
||||||
reader.releaseLock();
|
|
||||||
return { process: proc, url: match[0] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.releaseLock();
|
|
||||||
proc.kill();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerWithGateway(
|
export async function registerWithGateway(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
tunnelUrl: string,
|
localUrl: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
agentToken: string,
|
clientToken: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
|
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await resp.text();
|
const body = await resp.text();
|
||||||
@@ -77,12 +43,12 @@ export async function unregisterFromGateway(
|
|||||||
export function startHeartbeat(
|
export function startHeartbeat(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
tunnelUrl: string,
|
localUrl: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
agentToken: string,
|
clientToken: string,
|
||||||
intervalMs: number,
|
intervalMs: number,
|
||||||
): ReturnType<typeof setInterval> {
|
): ReturnType<typeof setInterval> {
|
||||||
return setInterval(() => {
|
return setInterval(() => {
|
||||||
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
|
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { dispatchConnect } from "./connect.js";
|
||||||
|
export type { ConnectOptions } from "./types.js";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type ConnectOptions = {
|
||||||
|
name: string;
|
||||||
|
gatewayUrl: string;
|
||||||
|
gatewaySecret: string;
|
||||||
|
};
|
||||||
+13
-14
@@ -5,7 +5,7 @@ export type GatewayWsClientParams = {
|
|||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
name: string;
|
name: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
localPort: number;
|
appFetch: (request: Request) => Response | Promise<Response>;
|
||||||
log: LogFn;
|
log: LogFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,20 +44,19 @@ async function handleGatewayMessage(
|
|||||||
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const localUrl = `http://127.0.0.1:${String(params.localPort)}${req.path}`;
|
const localUrl = `http://localhost${req.path}`;
|
||||||
const initHeaders = new Headers();
|
const headers = new Headers(req.headers);
|
||||||
for (const [k, v] of Object.entries(req.headers)) {
|
|
||||||
initHeaders.set(k, v);
|
|
||||||
}
|
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
resp = await fetch(localUrl, {
|
resp = await params.appFetch(
|
||||||
method: req.method,
|
new Request(localUrl, {
|
||||||
headers: initHeaders,
|
method: req.method,
|
||||||
body: req.body === null ? undefined : req.body,
|
headers,
|
||||||
});
|
body: req.body === null ? undefined : req.body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
params.log("R4N7BQ3C", `local proxy fetch failed: ${String(e)}`);
|
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||||
const errBody: WsResponse = {
|
const errBody: WsResponse = {
|
||||||
id: req.id,
|
id: req.id,
|
||||||
status: 502,
|
status: 502,
|
||||||
@@ -100,7 +99,7 @@ export function startGatewayWsClient(params: GatewayWsClientParams): () => void
|
|||||||
clearReconnectTimer();
|
clearReconnectTimer();
|
||||||
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||||
attempt++;
|
attempt++;
|
||||||
params.log("6CJX2RLP", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
||||||
reconnectTimer = setTimeout(connect, delayMs);
|
reconnectTimer = setTimeout(connect, delayMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +142,7 @@ export function startGatewayWsClient(params: GatewayWsClientParams): () => void
|
|||||||
ws.addEventListener("message", (ev) => {
|
ws.addEventListener("message", (ev) => {
|
||||||
const data = ev.data;
|
const data = ev.data;
|
||||||
if (typeof data !== "string") {
|
if (typeof data !== "string") {
|
||||||
params.log("T9W2KL5H", "gateway WebSocket non-text frame ignored");
|
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
||||||
@@ -51,7 +51,6 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
|||||||
description: "Says hello — replace with your first role.",
|
description: "Says hello — replace with your first role.",
|
||||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||||
schema: greeterMetaSchema,
|
schema: greeterMetaSchema,
|
||||||
extractRefs: null,
|
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,18 +196,13 @@ uncaged-workflow init workspace ${workspaceName}
|
|||||||
|
|
||||||
function bundleTs(): string {
|
function bundleTs(): string {
|
||||||
return [
|
return [
|
||||||
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
|
'import { mkdir, readdir, writeFile } from "node:fs/promises";',
|
||||||
'import { join } from "node:path";',
|
'import { join } from "node:path";',
|
||||||
"",
|
"",
|
||||||
'const rootDir = join(import.meta.dir, "..");',
|
'const rootDir = join(import.meta.dir, "..");',
|
||||||
'const workflowsDir = join(rootDir, "workflows");',
|
'const workflowsDir = join(rootDir, "workflows");',
|
||||||
'const distDir = join(rootDir, "dist");',
|
'const distDir = join(rootDir, "dist");',
|
||||||
"",
|
"",
|
||||||
"type JsonDeps = {",
|
|
||||||
" dependencies: Record<string, string> | null;",
|
|
||||||
" devDependencies: Record<string, string> | null;",
|
|
||||||
"};",
|
|
||||||
"",
|
|
||||||
"function isEntryFile(name: string): boolean {",
|
"function isEntryFile(name: string): boolean {",
|
||||||
' return name.endsWith("-entry.ts");',
|
' return name.endsWith("-entry.ts");',
|
||||||
"}",
|
"}",
|
||||||
@@ -216,36 +211,6 @@ function bundleTs(): string {
|
|||||||
' return name.slice(0, -".ts".length);',
|
' return name.slice(0, -".ts".length);',
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
"async function uncagedWorkflowExternals(): Promise<string[]> {",
|
|
||||||
" const names = new Set<string>();",
|
|
||||||
' const paths = [join(rootDir, "package.json"), join(workflowsDir, "package.json")];',
|
|
||||||
" for (const pkgPath of paths) {",
|
|
||||||
" let raw: string;",
|
|
||||||
" try {",
|
|
||||||
' raw = await readFile(pkgPath, "utf8");',
|
|
||||||
" } catch {",
|
|
||||||
" continue;",
|
|
||||||
" }",
|
|
||||||
" const parsed = JSON.parse(raw) as JsonDeps;",
|
|
||||||
" const blocks = [parsed.dependencies, parsed.devDependencies];",
|
|
||||||
" for (const block of blocks) {",
|
|
||||||
" if (block == null) {",
|
|
||||||
" continue;",
|
|
||||||
" }",
|
|
||||||
" for (const key of Object.keys(block)) {",
|
|
||||||
' if (key.startsWith("@uncaged/workflow")) {',
|
|
||||||
" names.add(key);",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" if (names.size === 0) {",
|
|
||||||
' names.add("@uncaged/workflow-runtime");',
|
|
||||||
' names.add("@uncaged/workflow-protocol");',
|
|
||||||
" }",
|
|
||||||
" return [...names];",
|
|
||||||
"}",
|
|
||||||
"",
|
|
||||||
"async function main(): Promise<void> {",
|
"async function main(): Promise<void> {",
|
||||||
" await mkdir(distDir, { recursive: true });",
|
" await mkdir(distDir, { recursive: true });",
|
||||||
" let files: string[];",
|
" let files: string[];",
|
||||||
@@ -261,7 +226,6 @@ function bundleTs(): string {
|
|||||||
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
||||||
" return;",
|
" return;",
|
||||||
" }",
|
" }",
|
||||||
" const external = await uncagedWorkflowExternals();",
|
|
||||||
" for (const file of entries) {",
|
" for (const file of entries) {",
|
||||||
" const stem = entryStem(file);",
|
" const stem = entryStem(file);",
|
||||||
" const entryPath = join(workflowsDir, file);",
|
" const entryPath = join(workflowsDir, file);",
|
||||||
@@ -272,7 +236,6 @@ function bundleTs(): string {
|
|||||||
' target: "node",',
|
' target: "node",',
|
||||||
" splitting: false,",
|
" splitting: false,",
|
||||||
' naming: { entry: "[name].esm.js" },',
|
' naming: { entry: "[name].esm.js" },',
|
||||||
" external,",
|
|
||||||
" });",
|
" });",
|
||||||
" if (!result.success) {",
|
" if (!result.success) {",
|
||||||
" for (const log of result.logs) {",
|
" for (const log of result.logs) {",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export { createApp } from "./app.js";
|
|
||||||
export { dispatchServe, startServer } from "./serve.js";
|
|
||||||
export type { ServeOptions } from "./types.js";
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { hostname as osHostname } from "node:os";
|
|
||||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
|
||||||
import { serve } from "bun";
|
|
||||||
|
|
||||||
import { printCliLine } from "../../cli-output.js";
|
|
||||||
import { createApp } from "./app.js";
|
|
||||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./tunnel.js";
|
|
||||||
import type { ServeOptions } from "./types.js";
|
|
||||||
import { startGatewayWsClient } from "./ws-client.js";
|
|
||||||
|
|
||||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
||||||
|
|
||||||
export function startServer(
|
|
||||||
storageRoot: string,
|
|
||||||
options: ServeOptions,
|
|
||||||
agentToken: string | null,
|
|
||||||
): void {
|
|
||||||
const app = createApp(storageRoot, agentToken);
|
|
||||||
|
|
||||||
const server = serve({
|
|
||||||
fetch: app.fetch,
|
|
||||||
port: options.port,
|
|
||||||
hostname: options.hostname,
|
|
||||||
});
|
|
||||||
|
|
||||||
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePortValue(value: string | undefined): Result<number, string> {
|
|
||||||
if (value === undefined) {
|
|
||||||
return err("--port requires a value");
|
|
||||||
}
|
|
||||||
const parsed = Number.parseInt(value, 10);
|
|
||||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
|
||||||
return err(`invalid port: ${value}`);
|
|
||||||
}
|
|
||||||
return ok(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
|
||||||
const next = argv[i + 1];
|
|
||||||
if (next === undefined) {
|
|
||||||
return err(`${flag} requires a value`);
|
|
||||||
}
|
|
||||||
return ok(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
|
||||||
let port = 7860;
|
|
||||||
let hostname = "127.0.0.1";
|
|
||||||
let name = osHostname().split(".")[0].toLowerCase();
|
|
||||||
let noTunnel = false;
|
|
||||||
let tunnelUrl: string | null = null;
|
|
||||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
|
||||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
|
||||||
const stringFlags: Record<string, (v: string) => void> = {
|
|
||||||
"--host": (v) => {
|
|
||||||
hostname = v;
|
|
||||||
},
|
|
||||||
"--name": (v) => {
|
|
||||||
name = v;
|
|
||||||
},
|
|
||||||
"--gateway": (v) => {
|
|
||||||
gatewayUrl = v;
|
|
||||||
},
|
|
||||||
"--tunnel-url": (v) => {
|
|
||||||
tunnelUrl = v;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
|
||||||
const arg = argv[i];
|
|
||||||
if (arg === "--port" || arg === "-p") {
|
|
||||||
const portResult = parsePortValue(argv[i + 1]);
|
|
||||||
if (!portResult.ok) return portResult;
|
|
||||||
port = portResult.value;
|
|
||||||
i++;
|
|
||||||
} else if (arg === "--no-tunnel") {
|
|
||||||
noTunnel = true;
|
|
||||||
} else if (arg in stringFlags) {
|
|
||||||
const r = requireNextArg(argv, i, arg);
|
|
||||||
if (!r.ok) return r;
|
|
||||||
stringFlags[arg](r.value);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok({ port, hostname, name, noTunnel, tunnelUrl, gatewayUrl, gatewaySecret });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const parsed = parseServeArgv(argv);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
printCliLine(`error: ${parsed.error}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = parsed.value;
|
|
||||||
const agentToken = options.noTunnel ? null : randomUUID();
|
|
||||||
startServer(storageRoot, options, agentToken);
|
|
||||||
|
|
||||||
if (options.noTunnel) {
|
|
||||||
printCliLine("tunnel disabled (--no-tunnel)");
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvedTunnelUrl: string;
|
|
||||||
let stopWsClient: (() => void) | null = null;
|
|
||||||
|
|
||||||
if (options.tunnelUrl !== null) {
|
|
||||||
resolvedTunnelUrl = options.tunnelUrl;
|
|
||||||
printCliLine(`using tunnel URL: ${resolvedTunnelUrl}`);
|
|
||||||
} else {
|
|
||||||
if (options.gatewaySecret === "") {
|
|
||||||
printCliLine(
|
|
||||||
"WORKFLOW_GATEWAY_SECRET not set — cannot use WebSocket gateway connection (set env or pass --tunnel-url)",
|
|
||||||
);
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
resolvedTunnelUrl = `http://127.0.0.1:${options.port}`;
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
|
||||||
stopWsClient = startGatewayWsClient({
|
|
||||||
gatewayUrl: options.gatewayUrl,
|
|
||||||
name: options.name,
|
|
||||||
secret: options.gatewaySecret,
|
|
||||||
localPort: options.port,
|
|
||||||
log,
|
|
||||||
});
|
|
||||||
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.gatewaySecret) {
|
|
||||||
if (agentToken === null) {
|
|
||||||
printCliLine("internal error: agent token missing");
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const token = agentToken;
|
|
||||||
const registered = await registerWithGateway(
|
|
||||||
options.gatewayUrl,
|
|
||||||
options.name,
|
|
||||||
resolvedTunnelUrl,
|
|
||||||
options.gatewaySecret,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
if (registered) {
|
|
||||||
printCliLine(`registered with gateway as "${options.name}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const heartbeatTimer = startHeartbeat(
|
|
||||||
options.gatewayUrl,
|
|
||||||
options.name,
|
|
||||||
resolvedTunnelUrl,
|
|
||||||
options.gatewaySecret,
|
|
||||||
token,
|
|
||||||
HEARTBEAT_INTERVAL_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
const cleanup = async () => {
|
|
||||||
clearInterval(heartbeatTimer);
|
|
||||||
stopWsClient?.();
|
|
||||||
printCliLine("unregistering from gateway...");
|
|
||||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGINT", cleanup);
|
|
||||||
process.on("SIGTERM", cleanup);
|
|
||||||
} else {
|
|
||||||
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type ServeOptions = {
|
|
||||||
port: number;
|
|
||||||
hostname: string;
|
|
||||||
name: string;
|
|
||||||
noTunnel: boolean;
|
|
||||||
tunnelUrl: string | null;
|
|
||||||
gatewayUrl: string;
|
|
||||||
gatewaySecret: string;
|
|
||||||
};
|
|
||||||
@@ -18,13 +18,13 @@ export async function cmdThreadRemove(
|
|||||||
return err(`thread not found: ${threadId}`);
|
return err(`thread not found: ${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolved.source === "active") {
|
// Always clear both stores: between resolve and delete the worker may finish and
|
||||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
// move the thread from threads.json into history; branching only on resolved.source
|
||||||
} else {
|
// would skip history removal and leave a dangling row.
|
||||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||||
if (!hist.ok) {
|
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||||
return hist;
|
if (!hist.ok) {
|
||||||
}
|
return hist;
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export async function cmdAdd(
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
const extracted = await extractBundleExports(resolvedPath);
|
||||||
if (!extracted.ok) {
|
if (!extracted.ok) {
|
||||||
return extracted;
|
return extracted;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,11 +86,11 @@ ${commandSections.join("\n\n")}
|
|||||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||||
|
|
||||||
### serve
|
### connect
|
||||||
|
|
||||||
| Command | Args | Description |
|
| Command | Args | Description |
|
||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
|
| \`connect\` | \`[--name NAME] [--gateway URL]\` | Connect to workflow gateway via WebSocket. \`--name\` registers with the gateway. |
|
||||||
|
|
||||||
## Typical Workflow
|
## Typical Workflow
|
||||||
|
|
||||||
@@ -249,8 +249,7 @@ Each role has:
|
|||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| \`description\` | string | What the role does |
|
| \`description\` | string | What the role does |
|
||||||
| \`systemPrompt\` | string | System prompt for the agent |
|
| \`systemPrompt\` | string | System prompt for the agent |
|
||||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
|
||||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
@@ -301,13 +300,36 @@ function createLazyAdapter(): AdapterFn {
|
|||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
### Agent CLI paths: use env() with absolute path defaults
|
||||||
|
|
||||||
|
Every env var in a bundle must have a sensible default — bundles must run without any env vars set. Use \`env(name, fallback)\` from \`@uncaged/workflow-util\`.
|
||||||
|
|
||||||
|
Discover the correct CLI path yourself (e.g. \`which cursor-agent\`, \`which hermes\`) and hardcode it as the fallback:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
import { env } from "@uncaged/workflow-util";
|
||||||
|
|
||||||
|
// ❌ WRONG — requireEnv and optionalEnv no longer exist
|
||||||
|
const adapter = createCursorAgent({
|
||||||
|
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set it"),
|
||||||
|
...
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ CORRECT — env var is an override, fallback is the discovered absolute path
|
||||||
|
const adapter = createCursorAgent({
|
||||||
|
command: env("WORKFLOW_CURSOR_COMMAND", "/home/you/.local/bin/cursor-agent"),
|
||||||
|
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
|
||||||
|
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "300000")),
|
||||||
|
...
|
||||||
|
});
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
### Bundle import restrictions
|
### Bundle import restrictions
|
||||||
|
|
||||||
The bundle validator only allows these import specifiers:
|
The bundle validator only allows these import specifiers:
|
||||||
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
||||||
- \`@uncaged/workflow-*\` packages
|
|
||||||
|
|
||||||
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
|
All other dependencies — including \`@uncaged/workflow-*\` packages, zod, and any third-party code — must be bundled into the \`.esm.js\` file. Bundles are fully self-contained: same Node/Bun version = same behavior.
|
||||||
|
|
||||||
### No default exports
|
### No default exports
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/uwf-agent-hermes",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"uwf-hermes": "./src/cli.ts"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/uwf-agent-kit": "workspace:^"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { createHermesAgent } from "./hermes.js";
|
||||||
|
|
||||||
|
const main = createHermesAgent();
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
import { type AgentContext, createAgent } from "@uncaged/uwf-agent-kit";
|
||||||
|
|
||||||
|
const HERMES_COMMAND = "hermes";
|
||||||
|
const HERMES_MAX_TURNS = 90;
|
||||||
|
|
||||||
|
function buildHistorySummary(history: AgentContext["history"]): string {
|
||||||
|
if (history.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = ["## Previous Steps"];
|
||||||
|
for (let i = 0; i < history.length; i++) {
|
||||||
|
const step = history[i];
|
||||||
|
if (step === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`### Step ${i + 1}: ${step.role}`);
|
||||||
|
lines.push(`Output: ${JSON.stringify(step.output)}`);
|
||||||
|
lines.push(`Agent: ${step.agent}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||||
|
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||||
|
const parts: string[] = [ctx.systemPrompt, "", "## Task", ctx.prompt];
|
||||||
|
const historyBlock = buildHistorySummary(ctx.history);
|
||||||
|
if (historyBlock !== "") {
|
||||||
|
parts.push("", historyBlock);
|
||||||
|
}
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnHermesChat(prompt: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
"chat",
|
||||||
|
"-q",
|
||||||
|
prompt,
|
||||||
|
"--yolo",
|
||||||
|
"--max-turns",
|
||||||
|
String(HERMES_MAX_TURNS),
|
||||||
|
"--quiet",
|
||||||
|
];
|
||||||
|
const child = spawn(HERMES_COMMAND, args, {
|
||||||
|
env: process.env,
|
||||||
|
shell: false,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (cause) => {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
reject(new Error(`hermes spawn failed: ${message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(stdout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
|
||||||
|
reject(new Error(`hermes exited with code ${code ?? "null"}${detail}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHermes(ctx: AgentContext): Promise<string> {
|
||||||
|
const fullPrompt = buildHermesPrompt(ctx);
|
||||||
|
return spawnHermesChat(fullPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
||||||
|
export function createHermesAgent(): () => Promise<void> {
|
||||||
|
return createAgent({
|
||||||
|
name: "hermes",
|
||||||
|
run: runHermes,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { buildHermesPrompt, createHermesAgent } from "./hermes.js";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../uwf-agent-kit" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { WorkflowConfig } from "@uncaged/uwf-protocol";
|
||||||
|
import { resolveExtractModelAlias } from "../src/extract.js";
|
||||||
|
|
||||||
|
function baseConfig(overrides: Partial<WorkflowConfig> = {}): WorkflowConfig {
|
||||||
|
return {
|
||||||
|
providers: {},
|
||||||
|
models: {
|
||||||
|
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
|
||||||
|
"gpt4o-mini": { provider: "openai", name: "gpt-4o-mini" },
|
||||||
|
},
|
||||||
|
agents: {},
|
||||||
|
defaultAgent: "hermes",
|
||||||
|
agentOverrides: null,
|
||||||
|
defaultModel: "sonnet",
|
||||||
|
modelOverrides: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveExtractModelAlias", () => {
|
||||||
|
test("uses modelOverrides.extract when set", () => {
|
||||||
|
const config = baseConfig({
|
||||||
|
modelOverrides: { extract: "gpt4o-mini" },
|
||||||
|
});
|
||||||
|
expect(resolveExtractModelAlias(config)).toBe("gpt4o-mini");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to models.extract alias when present", () => {
|
||||||
|
const config = baseConfig({
|
||||||
|
models: {
|
||||||
|
extract: { provider: "openai", name: "gpt-4o-mini" },
|
||||||
|
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resolveExtractModelAlias(config)).toBe("extract");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to defaultModel", () => {
|
||||||
|
expect(resolveExtractModelAlias(baseConfig())).toBe("sonnet");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/uwf-agent-kit",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/json-cas": "^0.1.3",
|
||||||
|
"@uncaged/json-cas-fs": "^0.1.2",
|
||||||
|
"@uncaged/uwf-protocol": "workspace:^",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"yaml": "^2.8.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import type {
|
||||||
|
CasRef,
|
||||||
|
StartNodePayload,
|
||||||
|
StepContext,
|
||||||
|
StepNodePayload,
|
||||||
|
ThreadId,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
|
||||||
|
import type { AgentContext } from "./types.js";
|
||||||
|
|
||||||
|
type ChainState = {
|
||||||
|
startHash: CasRef;
|
||||||
|
start: StartNodePayload;
|
||||||
|
stepsNewestFirst: StepNodePayload[];
|
||||||
|
headIsStart: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkChain(
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||||
|
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
|
||||||
|
headHash: CasRef,
|
||||||
|
): ChainState {
|
||||||
|
const headNode = store.get(headHash);
|
||||||
|
if (headNode === null) {
|
||||||
|
fail(`CAS node not found: ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type === schemas.startNode) {
|
||||||
|
return {
|
||||||
|
startHash: headHash,
|
||||||
|
start: headNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst: [],
|
||||||
|
headIsStart: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type !== schemas.stepNode) {
|
||||||
|
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsNewestFirst: StepNodePayload[] = [];
|
||||||
|
let hash: CasRef | null = headHash;
|
||||||
|
|
||||||
|
while (hash !== null) {
|
||||||
|
const node = store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found while walking chain: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== schemas.stepNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
stepsNewestFirst.push(payload);
|
||||||
|
hash = payload.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newest = stepsNewestFirst[0];
|
||||||
|
if (newest === undefined) {
|
||||||
|
fail(`empty step chain at head ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = store.get(newest.start);
|
||||||
|
if (startNode === null || startNode.type !== schemas.startNode) {
|
||||||
|
fail(`StartNode not found: ${newest.start}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startHash: newest.start,
|
||||||
|
start: startNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst,
|
||||||
|
headIsStart: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandOutput(
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||||
|
outputRef: CasRef,
|
||||||
|
): unknown {
|
||||||
|
const node = store.get(outputRef);
|
||||||
|
if (node === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return node.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildHistory(
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||||
|
stepsNewestFirst: StepNodePayload[],
|
||||||
|
): Promise<StepContext[]> {
|
||||||
|
const chronological = [...stepsNewestFirst].reverse();
|
||||||
|
const history: StepContext[] = [];
|
||||||
|
for (const step of chronological) {
|
||||||
|
history.push({
|
||||||
|
role: step.role,
|
||||||
|
output: expandOutput(store, step.output),
|
||||||
|
detail: step.detail,
|
||||||
|
agent: step.agent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkflow(
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||||
|
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
|
||||||
|
workflowRef: CasRef,
|
||||||
|
) {
|
||||||
|
const node = store.get(workflowRef);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||||
|
}
|
||||||
|
if (node.type !== schemas.workflow) {
|
||||||
|
fail(`node ${workflowRef} is not a Workflow`);
|
||||||
|
}
|
||||||
|
return node.payload as AgentContext["workflow"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build agent execution context from thread head in threads.yaml.
|
||||||
|
* Walks the CAS chain from head to StartNode and expands step outputs.
|
||||||
|
*/
|
||||||
|
export async function buildContext(threadId: ThreadId, role: string): Promise<AgentContext> {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
const agentStore = await createAgentStore(storageRoot);
|
||||||
|
const { store, schemas } = agentStore;
|
||||||
|
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const headHash = index[threadId];
|
||||||
|
if (headHash === undefined) {
|
||||||
|
fail(`thread not found in threads.yaml: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = walkChain(store, schemas, headHash);
|
||||||
|
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
|
||||||
|
const roleDef = workflow.roles[role];
|
||||||
|
if (roleDef === undefined) {
|
||||||
|
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await buildHistory(store, chain.stepsNewestFirst);
|
||||||
|
|
||||||
|
return {
|
||||||
|
threadId,
|
||||||
|
role,
|
||||||
|
systemPrompt: roleDef.systemPrompt,
|
||||||
|
prompt: chain.start.prompt,
|
||||||
|
history,
|
||||||
|
workflow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuildContextMeta = {
|
||||||
|
storageRoot: string;
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"];
|
||||||
|
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"];
|
||||||
|
headHash: CasRef;
|
||||||
|
chain: ChainState;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link buildContext} but also returns chain metadata for writing the next StepNode.
|
||||||
|
*/
|
||||||
|
export async function buildContextWithMeta(
|
||||||
|
threadId: ThreadId,
|
||||||
|
role: string,
|
||||||
|
): Promise<AgentContext & { meta: BuildContextMeta }> {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
const agentStore = await createAgentStore(storageRoot);
|
||||||
|
const { store, schemas } = agentStore;
|
||||||
|
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const headHash = index[threadId];
|
||||||
|
if (headHash === undefined) {
|
||||||
|
fail(`thread not found in threads.yaml: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = walkChain(store, schemas, headHash);
|
||||||
|
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
|
||||||
|
const roleDef = workflow.roles[role];
|
||||||
|
if (roleDef === undefined) {
|
||||||
|
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await buildHistory(store, chain.stepsNewestFirst);
|
||||||
|
|
||||||
|
return {
|
||||||
|
threadId,
|
||||||
|
role,
|
||||||
|
systemPrompt: roleDef.systemPrompt,
|
||||||
|
prompt: chain.start.prompt,
|
||||||
|
history,
|
||||||
|
workflow,
|
||||||
|
meta: { storageRoot, store, schemas, headHash, chain },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { getSchema, validate } from "@uncaged/json-cas";
|
||||||
|
|
||||||
|
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/uwf-protocol";
|
||||||
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
import { createAgentStore, getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||||
|
|
||||||
|
export type ResolvedLlmProvider = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve model alias for extract: modelOverrides.extract → models.extract → defaultModel. */
|
||||||
|
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||||
|
const fromOverride = config.modelOverrides?.extract ?? null;
|
||||||
|
if (fromOverride !== null) {
|
||||||
|
return fromOverride;
|
||||||
|
}
|
||||||
|
if (config.models.extract !== undefined) {
|
||||||
|
return "extract";
|
||||||
|
}
|
||||||
|
if (config.models.default !== undefined) {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
return config.defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
||||||
|
const modelEntry = config.models[alias];
|
||||||
|
if (modelEntry === undefined) {
|
||||||
|
throw new Error(`unknown model alias: ${alias}`);
|
||||||
|
}
|
||||||
|
const providerEntry = config.providers[modelEntry.provider];
|
||||||
|
if (providerEntry === undefined) {
|
||||||
|
throw new Error(`unknown provider "${modelEntry.provider}" for model "${alias}"`);
|
||||||
|
}
|
||||||
|
const apiKey = process.env[providerEntry.apiKeyEnv];
|
||||||
|
if (apiKey === undefined || apiKey === "") {
|
||||||
|
throw new Error(`missing API key env var: ${providerEntry.apiKeyEnv}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: providerEntry.baseUrl,
|
||||||
|
apiKey,
|
||||||
|
model: modelEntry.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatUrl(baseUrl: string): string {
|
||||||
|
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||||
|
return `${trimmed}/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonFromAssistantText(text: string): unknown {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
|
||||||
|
const candidate = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
|
||||||
|
return JSON.parse(candidate) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssistantText(parsed: unknown): string {
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
throw new Error("LLM response is not an object");
|
||||||
|
}
|
||||||
|
const choices = parsed.choices;
|
||||||
|
if (!Array.isArray(choices) || choices.length === 0) {
|
||||||
|
throw new Error("LLM response has no choices");
|
||||||
|
}
|
||||||
|
const c0 = choices[0];
|
||||||
|
if (!isRecord(c0)) {
|
||||||
|
throw new Error("LLM choice is not an object");
|
||||||
|
}
|
||||||
|
const messageObj = c0.message;
|
||||||
|
if (!isRecord(messageObj)) {
|
||||||
|
throw new Error("LLM message is not an object");
|
||||||
|
}
|
||||||
|
const content = messageObj.content;
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
throw new Error("LLM message has no text content");
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatCompletionText(
|
||||||
|
provider: ResolvedLlmProvider,
|
||||||
|
messages: Array<{ role: "system" | "user"; content: string }>,
|
||||||
|
): Promise<string> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(chatUrl(provider.baseUrl), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${provider.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: provider.model,
|
||||||
|
messages,
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
throw new Error(`LLM network error: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`LLM HTTP ${response.status}: ${responseText.slice(0, 2000)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(responseText) as unknown;
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
throw new Error(`LLM invalid JSON response: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseAssistantText(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtractResult = {
|
||||||
|
value: unknown;
|
||||||
|
hash: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call an OpenAI-compatible LLM to extract structured output matching outputSchema.
|
||||||
|
* Loads config.yaml and .env from the workflow storage root.
|
||||||
|
*/
|
||||||
|
export async function extract(
|
||||||
|
rawOutput: string,
|
||||||
|
outputSchema: CasRef,
|
||||||
|
config: WorkflowConfig,
|
||||||
|
): Promise<ExtractResult> {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||||
|
|
||||||
|
const { store } = await createAgentStore(storageRoot);
|
||||||
|
const schema = getSchema(store, outputSchema);
|
||||||
|
if (schema === null) {
|
||||||
|
throw new Error(`output schema not found in CAS: ${outputSchema}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelAlias = resolveExtractModelAlias(config);
|
||||||
|
const provider = resolveModel(config, modelAlias);
|
||||||
|
|
||||||
|
const schemaText = JSON.stringify(schema, null, 2);
|
||||||
|
const assistantText = await chatCompletionText(provider, [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"Extract structured data from the agent output. Reply with a single JSON object only, no markdown or prose. The JSON must validate against this JSON Schema:\n" +
|
||||||
|
schemaText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: rawOutput,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let structured: unknown;
|
||||||
|
try {
|
||||||
|
structured = extractJsonFromAssistantText(assistantText);
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
throw new Error(`failed to parse extracted JSON: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputHash = await store.put(outputSchema, structured);
|
||||||
|
const node = store.get(outputHash);
|
||||||
|
if (node === null || !validate(store, node)) {
|
||||||
|
throw new Error("extracted output failed JSON Schema validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: structured, hash: outputHash };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export type { BuildContextMeta } from "./context.js";
|
||||||
|
export { buildContext, buildContextWithMeta } from "./context.js";
|
||||||
|
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||||
|
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
|
||||||
|
export {
|
||||||
|
extract,
|
||||||
|
resolveExtractModelAlias,
|
||||||
|
resolveModel,
|
||||||
|
} from "./extract.js";
|
||||||
|
export { createAgent } from "./run.js";
|
||||||
|
export type { AgentContext, AgentOptions, AgentRunFn } from "./types.js";
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { validate } from "@uncaged/json-cas";
|
||||||
|
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol";
|
||||||
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
|
||||||
|
import { buildContextWithMeta } from "./context.js";
|
||||||
|
import { extract } from "./extract.js";
|
||||||
|
import type { AgentStore } from "./storage.js";
|
||||||
|
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||||
|
import type { AgentContext, AgentOptions } from "./types.js";
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentLabel(name: string): string {
|
||||||
|
if (name.startsWith("uwf-")) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return `uwf-${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgv(argv: string[]): { threadId: ThreadId; role: string } {
|
||||||
|
const threadId = argv[2];
|
||||||
|
const role = argv[3];
|
||||||
|
if (threadId === undefined || threadId === "") {
|
||||||
|
fail("usage: <agent-cli> <thread-id> <role>");
|
||||||
|
}
|
||||||
|
if (role === undefined || role === "") {
|
||||||
|
fail("usage: <agent-cli> <thread-id> <role>");
|
||||||
|
}
|
||||||
|
return { threadId: threadId as ThreadId, role };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
return fn().catch((e: unknown) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
fail(`${label}: ${message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStepNode(options: {
|
||||||
|
store: AgentStore["store"];
|
||||||
|
schemas: AgentStore["schemas"];
|
||||||
|
startHash: CasRef;
|
||||||
|
prevHash: CasRef | null;
|
||||||
|
role: string;
|
||||||
|
outputHash: CasRef;
|
||||||
|
detailHash: CasRef;
|
||||||
|
agentName: string;
|
||||||
|
}): Promise<CasRef> {
|
||||||
|
const payload: StepNodePayload = {
|
||||||
|
start: options.startHash,
|
||||||
|
prev: options.prevHash,
|
||||||
|
role: options.role,
|
||||||
|
output: options.outputHash,
|
||||||
|
detail: options.detailHash,
|
||||||
|
agent: options.agentName,
|
||||||
|
};
|
||||||
|
const hash = await options.store.put(options.schemas.stepNode, payload);
|
||||||
|
const node = options.store.get(hash);
|
||||||
|
if (node === null || !validate(options.store, node)) {
|
||||||
|
fail("stored StepNode failed schema validation");
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<string> {
|
||||||
|
return runWithMessage("agent run failed", () => options.run(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractOutput(
|
||||||
|
rawOutput: string,
|
||||||
|
outputSchema: CasRef,
|
||||||
|
storageRoot: string,
|
||||||
|
): Promise<CasRef> {
|
||||||
|
const config = await runWithMessage("failed to load config", () =>
|
||||||
|
loadWorkflowConfig(storageRoot),
|
||||||
|
);
|
||||||
|
const extracted = await runWithMessage("extract failed", () =>
|
||||||
|
extract(rawOutput, outputSchema, config),
|
||||||
|
);
|
||||||
|
return extracted.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistStep(options: {
|
||||||
|
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>;
|
||||||
|
rawOutput: string;
|
||||||
|
outputHash: CasRef;
|
||||||
|
agentName: string;
|
||||||
|
}): Promise<CasRef> {
|
||||||
|
const { store, schemas, chain, headHash } = options.ctx.meta;
|
||||||
|
const detailHash = await store.put(null, options.rawOutput);
|
||||||
|
return writeStepNode({
|
||||||
|
store,
|
||||||
|
schemas,
|
||||||
|
startHash: chain.startHash,
|
||||||
|
prevHash: chain.headIsStart ? null : headHash,
|
||||||
|
role: options.ctx.role,
|
||||||
|
outputHash: options.outputHash,
|
||||||
|
detailHash,
|
||||||
|
agentName: options.agentName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an agent CLI entrypoint.
|
||||||
|
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
|
||||||
|
* writes StepNode to CAS, and prints the new node hash to stdout.
|
||||||
|
*/
|
||||||
|
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||||
|
return async function main(): Promise<void> {
|
||||||
|
const { threadId, role } = parseArgv(process.argv);
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||||
|
|
||||||
|
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role));
|
||||||
|
|
||||||
|
const roleDef = ctx.workflow.roles[role];
|
||||||
|
if (roleDef === undefined) {
|
||||||
|
fail(`unknown role: ${role}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawOutput = await runAgent(options, ctx);
|
||||||
|
const outputHash = await extractOutput(rawOutput, roleDef.outputSchema, storageRoot);
|
||||||
|
const stepHash = await persistStep({
|
||||||
|
ctx,
|
||||||
|
rawOutput,
|
||||||
|
outputHash,
|
||||||
|
agentName: agentLabel(options.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdout.write(`${stepHash}\n`);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Hash, Store } from "@uncaged/json-cas";
|
||||||
|
import { putSchema } from "@uncaged/json-cas";
|
||||||
|
import {
|
||||||
|
START_NODE_SCHEMA,
|
||||||
|
STEP_NODE_SCHEMA,
|
||||||
|
WORKFLOW_SCHEMA,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
export type UwfAgentSchemaHashes = {
|
||||||
|
workflow: Hash;
|
||||||
|
startNode: Hash;
|
||||||
|
stepNode: Hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
|
||||||
|
* Idempotent: safe to call on every agent invocation.
|
||||||
|
*/
|
||||||
|
export async function registerAgentSchemas(store: Store): Promise<UwfAgentSchemaHashes> {
|
||||||
|
const [workflow, startNode, stepNode] = await Promise.all([
|
||||||
|
putSchema(store, WORKFLOW_SCHEMA),
|
||||||
|
putSchema(store, START_NODE_SCHEMA),
|
||||||
|
putSchema(store, STEP_NODE_SCHEMA),
|
||||||
|
]);
|
||||||
|
return { workflow, startNode, stepNode };
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { Store } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import type {
|
||||||
|
AgentAlias,
|
||||||
|
AgentConfig,
|
||||||
|
ModelAlias,
|
||||||
|
ModelConfig,
|
||||||
|
ProviderAlias,
|
||||||
|
ProviderConfig,
|
||||||
|
Scenario,
|
||||||
|
ThreadId,
|
||||||
|
ThreadsIndex,
|
||||||
|
WorkflowConfig,
|
||||||
|
WorkflowName,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
import { registerAgentSchemas } from "./schemas.js";
|
||||||
|
|
||||||
|
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||||
|
export function getDefaultStorageRoot(): string {
|
||||||
|
return join(homedir(), ".uncaged", "workflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve storage root.
|
||||||
|
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
|
||||||
|
*/
|
||||||
|
export function resolveStorageRoot(): string {
|
||||||
|
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (internal !== undefined && internal !== "") {
|
||||||
|
return internal;
|
||||||
|
}
|
||||||
|
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (userOverride !== undefined && userOverride !== "") {
|
||||||
|
return userOverride;
|
||||||
|
}
|
||||||
|
return getDefaultStorageRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCasDir(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "cas");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "config.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, ".env");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThreadsPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "threads.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentStore = {
|
||||||
|
storageRoot: string;
|
||||||
|
store: Store;
|
||||||
|
schemas: Awaited<ReturnType<typeof registerAgentSchemas>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createAgentStore(storageRoot: string): Promise<AgentStore> {
|
||||||
|
const store = createFsStore(getCasDir(storageRoot));
|
||||||
|
const schemas = await registerAgentSchemas(store);
|
||||||
|
return { storageRoot, store, schemas };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProviders(raw: unknown): Record<ProviderAlias, ProviderConfig> {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.providers must be a mapping");
|
||||||
|
}
|
||||||
|
const providers: Record<ProviderAlias, ProviderConfig> = {};
|
||||||
|
for (const [name, entry] of Object.entries(raw)) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
throw new Error(`config.providers.${name} must be a mapping`);
|
||||||
|
}
|
||||||
|
const baseUrl = entry.baseUrl;
|
||||||
|
const apiKeyEnv = entry.apiKeyEnv;
|
||||||
|
if (typeof baseUrl !== "string" || typeof apiKeyEnv !== "string") {
|
||||||
|
throw new Error(`config.providers.${name} requires baseUrl and apiKeyEnv`);
|
||||||
|
}
|
||||||
|
providers[name] = { baseUrl, apiKeyEnv };
|
||||||
|
}
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModels(raw: unknown): Record<ModelAlias, ModelConfig> {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.models must be a mapping");
|
||||||
|
}
|
||||||
|
const models: Record<ModelAlias, ModelConfig> = {};
|
||||||
|
for (const [name, entry] of Object.entries(raw)) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
throw new Error(`config.models.${name} must be a mapping`);
|
||||||
|
}
|
||||||
|
const provider = entry.provider;
|
||||||
|
const modelName = entry.name;
|
||||||
|
if (typeof provider !== "string" || typeof modelName !== "string") {
|
||||||
|
throw new Error(`config.models.${name} requires provider and name`);
|
||||||
|
}
|
||||||
|
models[name] = { provider, name: modelName };
|
||||||
|
}
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgents(raw: unknown): Record<AgentAlias, AgentConfig> {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.agents must be a mapping");
|
||||||
|
}
|
||||||
|
const agents: Record<AgentAlias, AgentConfig> = {};
|
||||||
|
for (const [name, entry] of Object.entries(raw)) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
throw new Error(`config.agents.${name} must be a mapping`);
|
||||||
|
}
|
||||||
|
const command = entry.command;
|
||||||
|
const argsRaw = entry.args;
|
||||||
|
if (typeof command !== "string") {
|
||||||
|
throw new Error(`config.agents.${name} requires command`);
|
||||||
|
}
|
||||||
|
const args = Array.isArray(argsRaw)
|
||||||
|
? argsRaw.filter((a): a is string => typeof a === "string")
|
||||||
|
: [];
|
||||||
|
agents[name] = { command, args };
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelOverrides(raw: unknown): Record<Scenario, ModelAlias> | null {
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.modelOverrides must be a mapping or null");
|
||||||
|
}
|
||||||
|
const overrides: Record<Scenario, ModelAlias> = {};
|
||||||
|
for (const [scene, alias] of Object.entries(raw)) {
|
||||||
|
if (typeof alias === "string") {
|
||||||
|
overrides[scene] = alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgentOverrides(
|
||||||
|
raw: unknown,
|
||||||
|
): Record<WorkflowName, Record<string, AgentAlias>> | null {
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.agentOverrides must be a mapping or null");
|
||||||
|
}
|
||||||
|
const overrides: Record<WorkflowName, Record<string, AgentAlias>> = {};
|
||||||
|
for (const [workflowName, rolesRaw] of Object.entries(raw)) {
|
||||||
|
if (!isRecord(rolesRaw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const roles: Record<string, AgentAlias> = {};
|
||||||
|
for (const [roleName, alias] of Object.entries(rolesRaw)) {
|
||||||
|
if (typeof alias === "string") {
|
||||||
|
roles[roleName] = alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overrides[workflowName] = roles;
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkflowConfig(raw: unknown): WorkflowConfig {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.yaml root must be a mapping");
|
||||||
|
}
|
||||||
|
const defaultAgent = raw.defaultAgent;
|
||||||
|
const defaultModel = raw.defaultModel;
|
||||||
|
if (typeof defaultAgent !== "string" || typeof defaultModel !== "string") {
|
||||||
|
throw new Error("config requires defaultAgent and defaultModel");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
providers: normalizeProviders(raw.providers),
|
||||||
|
models: normalizeModels(raw.models),
|
||||||
|
agents: normalizeAgents(raw.agents),
|
||||||
|
defaultAgent,
|
||||||
|
agentOverrides: normalizeAgentOverrides(raw.agentOverrides),
|
||||||
|
defaultModel,
|
||||||
|
modelOverrides: normalizeModelOverrides(raw.modelOverrides),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig> {
|
||||||
|
const path = getConfigPath(storageRoot);
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = parse(text) as unknown;
|
||||||
|
return normalizeWorkflowConfig(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
|
||||||
|
const path = getThreadsPath(storageRoot);
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = parse(text) as unknown;
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const index: ThreadsIndex = {};
|
||||||
|
for (const [threadId, head] of Object.entries(raw)) {
|
||||||
|
if (typeof head === "string") {
|
||||||
|
index[threadId as ThreadId] = head;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { StepContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
export type AgentContext = {
|
||||||
|
threadId: ThreadId;
|
||||||
|
role: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
prompt: string;
|
||||||
|
history: StepContext[];
|
||||||
|
workflow: WorkflowPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentRunFn = (ctx: AgentContext) => Promise<string>;
|
||||||
|
|
||||||
|
export type AgentOptions = {
|
||||||
|
name: string;
|
||||||
|
run: AgentRunFn;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../uwf-protocol" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
import { evaluate } from "../src/evaluate.js";
|
||||||
|
|
||||||
|
const solveIssueWorkflow: WorkflowPayload = {
|
||||||
|
name: "solve-issue",
|
||||||
|
description: "End-to-end issue resolution",
|
||||||
|
roles: {
|
||||||
|
planner: {
|
||||||
|
description: "Creates implementation plan",
|
||||||
|
systemPrompt: "You are a planning agent...",
|
||||||
|
outputSchema: "5GWKR8TN1V3JA",
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
description: "Implements code changes",
|
||||||
|
systemPrompt: "You are a developer agent...",
|
||||||
|
outputSchema: "8CNWT4KR6D1HV",
|
||||||
|
},
|
||||||
|
reviewer: {
|
||||||
|
description: "Reviews code changes",
|
||||||
|
systemPrompt: "You are a code reviewer...",
|
||||||
|
outputSchema: "1VPBG9SM5E7WK",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {
|
||||||
|
needsClarification: {
|
||||||
|
description: "Planner requests clarification from user",
|
||||||
|
expression: "$exists(steps[-1].output.needsClarification)",
|
||||||
|
},
|
||||||
|
notApproved: {
|
||||||
|
description: "Reviewer rejected the implementation",
|
||||||
|
expression: "steps[-1].output.approved = false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
$START: [{ role: "planner", condition: null }],
|
||||||
|
planner: [
|
||||||
|
{ role: "developer", condition: "needsClarification" },
|
||||||
|
{ role: "$END", condition: null },
|
||||||
|
],
|
||||||
|
developer: [{ role: "reviewer", condition: null }],
|
||||||
|
reviewer: [
|
||||||
|
{ role: "developer", condition: "notApproved" },
|
||||||
|
{ role: "$END", condition: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
||||||
|
return {
|
||||||
|
start: {
|
||||||
|
workflow: "4KNM2PXR3B1QW",
|
||||||
|
prompt: "Fix the login bug",
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("evaluate", () => {
|
||||||
|
test("$START → first role (fallback)", async () => {
|
||||||
|
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
||||||
|
expect(result).toEqual({ ok: true, value: "planner" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("condition match (notApproved → developer)", async () => {
|
||||||
|
const context = makeContext([
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
detail: "2MXBG6PN4A8JR",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
|
expect(result).toEqual({ ok: true, value: "developer" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fallback when condition does not match → $END", async () => {
|
||||||
|
const context = makeContext([
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
detail: "2MXBG6PN4A8JR",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
|
expect(result).toEqual({ ok: true, value: "$END" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing role in graph → error", async () => {
|
||||||
|
const context = makeContext([
|
||||||
|
{
|
||||||
|
role: "unknown-role",
|
||||||
|
output: {},
|
||||||
|
detail: "2MXBG6PN4A8JR",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("output expansion in context works with JSONata", async () => {
|
||||||
|
const context = makeContext([
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { needsClarification: true },
|
||||||
|
detail: "7BQST3VW9F2MA",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
|
expect(result).toEqual({ ok: true, value: "developer" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/uwf-moderator",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/uwf-protocol": "workspace:^",
|
||||||
|
"jsonata": "^1.8.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
import jsonata from "jsonata";
|
||||||
|
|
||||||
|
import type { Result } from "./types.js";
|
||||||
|
|
||||||
|
const START_ROLE = "$START";
|
||||||
|
|
||||||
|
function isTruthy(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value !== 0 && !Number.isNaN(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateJsonata(expression: string, context: ModeratorContext): Promise<Result<unknown, Error>> {
|
||||||
|
try {
|
||||||
|
const result = await jsonata(expression).evaluate(context);
|
||||||
|
return { ok: true, value: result };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error : new Error(String(error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentRole(context: ModeratorContext): string {
|
||||||
|
if (context.steps.length === 0) {
|
||||||
|
return START_ROLE;
|
||||||
|
}
|
||||||
|
return context.steps[context.steps.length - 1].role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluate(
|
||||||
|
workflow: WorkflowPayload,
|
||||||
|
context: ModeratorContext,
|
||||||
|
): Promise<Result<string, Error>> {
|
||||||
|
const role = currentRole(context);
|
||||||
|
const transitions = workflow.graph[role];
|
||||||
|
if (transitions === undefined) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(`no transitions defined for role "${role}"`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transition of transitions) {
|
||||||
|
if (transition.condition === null) {
|
||||||
|
return { ok: true, value: transition.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionDef = workflow.conditions[transition.condition];
|
||||||
|
if (conditionDef === undefined) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(`unknown condition "${transition.condition}"`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalResult = await evaluateJsonata(conditionDef.expression, context);
|
||||||
|
if (!evalResult.ok) {
|
||||||
|
return evalResult;
|
||||||
|
}
|
||||||
|
if (isTruthy(evalResult.value)) {
|
||||||
|
return { ok: true, value: transition.role };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(`no transition matched for role "${role}"`),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { evaluate } from "./evaluate.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../uwf-protocol" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/uwf-protocol",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/json-cas-fs": "^0.1.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export {
|
||||||
|
START_NODE_SCHEMA,
|
||||||
|
STEP_NODE_SCHEMA,
|
||||||
|
WORKFLOW_SCHEMA,
|
||||||
|
} from "./schemas.js";
|
||||||
|
export type {
|
||||||
|
AgentAlias,
|
||||||
|
AgentConfig,
|
||||||
|
CasRef,
|
||||||
|
ConditionDefinition,
|
||||||
|
ModelAlias,
|
||||||
|
ModelConfig,
|
||||||
|
ModeratorContext,
|
||||||
|
ProviderAlias,
|
||||||
|
ProviderConfig,
|
||||||
|
RoleDefinition,
|
||||||
|
RoleName,
|
||||||
|
Scenario,
|
||||||
|
StartNodePayload,
|
||||||
|
StartOutput,
|
||||||
|
StepContext,
|
||||||
|
StepNodePayload,
|
||||||
|
StepOutput,
|
||||||
|
StepRecord,
|
||||||
|
ThreadId,
|
||||||
|
ThreadListItem,
|
||||||
|
ThreadsIndex,
|
||||||
|
Transition,
|
||||||
|
WorkflowConfig,
|
||||||
|
WorkflowName,
|
||||||
|
WorkflowPayload,
|
||||||
|
} from "./types.js";
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
|
|
||||||
|
const ROLE_DEFINITION: JSONSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["description", "systemPrompt", "outputSchema"],
|
||||||
|
properties: {
|
||||||
|
description: { type: "string" },
|
||||||
|
systemPrompt: { type: "string" },
|
||||||
|
outputSchema: { type: "string", format: "cas_ref" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONDITION_DEFINITION: JSONSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["description", "expression"],
|
||||||
|
properties: {
|
||||||
|
description: { type: "string" },
|
||||||
|
expression: { type: "string" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSITION: JSONSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["role", "condition"],
|
||||||
|
properties: {
|
||||||
|
role: { type: "string" },
|
||||||
|
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||||
|
title: "Workflow",
|
||||||
|
type: "object",
|
||||||
|
required: ["name", "description", "roles", "conditions", "graph"],
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
roles: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: ROLE_DEFINITION,
|
||||||
|
},
|
||||||
|
conditions: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: CONDITION_DEFINITION,
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "array",
|
||||||
|
items: TRANSITION,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const START_NODE_SCHEMA: JSONSchema = {
|
||||||
|
title: "StartNode",
|
||||||
|
type: "object",
|
||||||
|
required: ["workflow", "prompt"],
|
||||||
|
properties: {
|
||||||
|
workflow: { type: "string", format: "cas_ref" },
|
||||||
|
prompt: { type: "string" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||||
|
title: "StepNode",
|
||||||
|
type: "object",
|
||||||
|
required: ["start", "prev", "role", "output", "detail", "agent"],
|
||||||
|
properties: {
|
||||||
|
start: { type: "string", format: "cas_ref" },
|
||||||
|
prev: {
|
||||||
|
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||||
|
},
|
||||||
|
role: { type: "string" },
|
||||||
|
output: { type: "string", format: "cas_ref" },
|
||||||
|
detail: { type: "string", format: "cas_ref" },
|
||||||
|
agent: { type: "string" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// ── 4.1 公共类型 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** CAS hash — XXH64, 13-char Crockford Base32 */
|
||||||
|
export type CasRef = string;
|
||||||
|
|
||||||
|
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||||
|
export type ThreadId = string;
|
||||||
|
|
||||||
|
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||||
|
export type StepRecord = {
|
||||||
|
role: string;
|
||||||
|
output: CasRef;
|
||||||
|
detail: CasRef;
|
||||||
|
agent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RoleDefinition = {
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
outputSchema: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Transition = {
|
||||||
|
role: string;
|
||||||
|
condition: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConditionDefinition = {
|
||||||
|
description: string;
|
||||||
|
expression: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowPayload = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
roles: Record<string, RoleDefinition>;
|
||||||
|
conditions: Record<string, ConditionDefinition>;
|
||||||
|
graph: Record<string, Transition[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type StartNodePayload = {
|
||||||
|
workflow: CasRef;
|
||||||
|
prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StepNodePayload = StepRecord & {
|
||||||
|
start: CasRef;
|
||||||
|
prev: CasRef | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.4 JSONata 求值上下文 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** JSONata 上下文中的 step — output 被展开 */
|
||||||
|
export type StepContext = Omit<StepRecord, "output"> & {
|
||||||
|
output: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModeratorContext = {
|
||||||
|
start: StartNodePayload;
|
||||||
|
steps: StepContext[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** uwf thread start */
|
||||||
|
export type StartOutput = {
|
||||||
|
workflow: CasRef;
|
||||||
|
thread: ThreadId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread step / uwf thread show */
|
||||||
|
export type StepOutput = {
|
||||||
|
workflow: CasRef;
|
||||||
|
thread: ThreadId;
|
||||||
|
head: CasRef;
|
||||||
|
done: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread list */
|
||||||
|
export type ThreadListItem = {
|
||||||
|
thread: ThreadId;
|
||||||
|
workflow: CasRef;
|
||||||
|
head: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.6 配置 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Alias types for config references */
|
||||||
|
export type AgentAlias = string;
|
||||||
|
export type ModelAlias = string;
|
||||||
|
export type ProviderAlias = string;
|
||||||
|
export type WorkflowName = string;
|
||||||
|
export type RoleName = string;
|
||||||
|
export type Scenario = string;
|
||||||
|
|
||||||
|
export type ProviderConfig = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKeyEnv: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelConfig = {
|
||||||
|
provider: ProviderAlias;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentConfig = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ~/.uncaged/workflow/config.yaml */
|
||||||
|
export type WorkflowConfig = {
|
||||||
|
providers: Record<ProviderAlias, ProviderConfig>;
|
||||||
|
models: Record<ModelAlias, ModelConfig>;
|
||||||
|
agents: Record<AgentAlias, AgentConfig>;
|
||||||
|
defaultAgent: AgentAlias;
|
||||||
|
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
||||||
|
defaultModel: ModelAlias;
|
||||||
|
modelOverrides: Record<Scenario, ModelAlias> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ~/.uncaged/workflow/threads.yaml */
|
||||||
|
export type ThreadsIndex = Record<ThreadId, CasRef>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# @uncaged/workflow-agent-cursor
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||||
|
|
||||||
|
## 0.4.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.4.5
|
||||||
|
- @uncaged/workflow-reactor@0.4.5
|
||||||
|
- @uncaged/workflow-runtime@0.4.5
|
||||||
|
- @uncaged/workflow-util@0.4.5
|
||||||
|
- @uncaged/workflow-util-agent@0.4.5
|
||||||
|
|
||||||
|
## 0.4.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.4.4
|
||||||
|
- @uncaged/workflow-reactor@0.4.4
|
||||||
|
- @uncaged/workflow-runtime@0.4.4
|
||||||
|
- @uncaged/workflow-util@0.4.4
|
||||||
|
- @uncaged/workflow-util-agent@0.4.4
|
||||||
|
|
||||||
|
## 0.4.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.4.3
|
||||||
|
- @uncaged/workflow-reactor@0.4.3
|
||||||
|
- @uncaged/workflow-runtime@0.4.3
|
||||||
|
- @uncaged/workflow-util-agent@0.4.3
|
||||||
|
- @uncaged/workflow-util@0.4.3
|
||||||
|
|
||||||
|
## 0.4.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.4.2
|
||||||
|
- @uncaged/workflow-reactor@0.4.2
|
||||||
|
- @uncaged/workflow-runtime@0.4.2
|
||||||
|
- @uncaged/workflow-util-agent@0.4.2
|
||||||
|
- @uncaged/workflow-util@0.4.2
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Fix package exports for published packages and adopt changesets for version management.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.4.0
|
||||||
|
- @uncaged/workflow-reactor@0.4.0
|
||||||
|
- @uncaged/workflow-runtime@0.4.0
|
||||||
|
- @uncaged/workflow-util-agent@0.4.0
|
||||||
|
- @uncaged/workflow-util@0.4.0
|
||||||
@@ -1,36 +1,25 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||||
|
|
||||||
describe("validateCursorAgentConfig", () => {
|
const baseConfig = {
|
||||||
test("accepts valid config with explicit workspace", () => {
|
command: "/usr/local/bin/cursor-agent",
|
||||||
const r = validateCursorAgentConfig({
|
model: null as string | null,
|
||||||
command: "/usr/local/bin/cursor-agent",
|
timeout: 0,
|
||||||
model: null,
|
workspace: null as string | null,
|
||||||
timeout: 0,
|
};
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts valid config with null workspace and llmProvider", () => {
|
describe("validateCursorAgentConfig", () => {
|
||||||
|
test("accepts valid config", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: null,
|
|
||||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects non-absolute command", () => {
|
test("rejects non-absolute command", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
|
...baseConfig,
|
||||||
command: "cursor-agent",
|
command: "cursor-agent",
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -38,87 +27,38 @@ describe("validateCursorAgentConfig", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects empty workspace string", () => {
|
test("rejects negative timeout", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
timeout: -1,
|
||||||
timeout: 0,
|
});
|
||||||
workspace: "",
|
expect(r.ok).toBe(false);
|
||||||
llmProvider: null,
|
});
|
||||||
|
|
||||||
|
test("rejects non-absolute workspace when set", () => {
|
||||||
|
const r = validateCursorAgentConfig({
|
||||||
|
...baseConfig,
|
||||||
|
workspace: "relative/path",
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
expect(r.error).toContain("workspace");
|
expect(r.error).toContain("workspace");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects null workspace without llmProvider", () => {
|
|
||||||
const r = validateCursorAgentConfig({
|
|
||||||
command: "/usr/local/bin/cursor-agent",
|
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: null,
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.error).toContain("llmProvider");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects negative timeout", () => {
|
|
||||||
const r = validateCursorAgentConfig({
|
|
||||||
command: "/usr/local/bin/cursor-agent",
|
|
||||||
model: null,
|
|
||||||
timeout: -1,
|
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createCursorAgent", () => {
|
describe("createCursorAgent", () => {
|
||||||
test("returns an AdapterFn with explicit workspace", () => {
|
test("returns an AdapterFn", () => {
|
||||||
const agent = createCursorAgent({
|
const agent = createCursorAgent({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns an AdapterFn with null workspace and llmProvider", () => {
|
|
||||||
const agent = createCursorAgent({
|
|
||||||
command: "/usr/local/bin/cursor-agent",
|
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: null,
|
|
||||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
|
||||||
});
|
});
|
||||||
expect(typeof agent).toBe("function");
|
expect(typeof agent).toBe("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
||||||
const agent = createCursorAgent({
|
const agent = createCursorAgent({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: -1,
|
timeout: -1,
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
|
|
||||||
const agent = createCursorAgent({
|
|
||||||
command: "/usr/local/bin/cursor-agent",
|
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: null,
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
});
|
||||||
expect(typeof agent).toBe("function");
|
expect(typeof agent).toBe("function");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-cursor",
|
"name": "@uncaged/workflow-agent-cursor",
|
||||||
"version": "0.3.21",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:*",
|
"@uncaged/workflow-cas": "workspace:^",
|
||||||
"@uncaged/workflow-reactor": "workspace:*",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-runtime": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:*",
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*",
|
"@uncaged/workflow-util-agent": "workspace:^",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
|
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||||
import type { LogFn } from "@uncaged/workflow-util";
|
import type { LogFn } from "@uncaged/workflow-util";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
@@ -7,10 +7,7 @@ const workspaceSchema = z.object({
|
|||||||
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
|
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const EXTRACT_SYSTEM_FN = (_toolName: string) =>
|
function buildExtractionInput(ctx: ThreadContext): string {
|
||||||
`You are a workspace-path extractor. Given a workflow agent context (task description and previous step outputs), identify the absolute filesystem path of the project workspace where code changes should be made. Call the tool with the absolute path.`;
|
|
||||||
|
|
||||||
function buildExtractionInput(ctx: AgentContext): string {
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push("## Task");
|
lines.push("## Task");
|
||||||
lines.push(ctx.start.content);
|
lines.push(ctx.start.content);
|
||||||
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
|
|||||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
|
||||||
|
);
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractWorkspacePath(
|
export async function extractWorkspacePath(
|
||||||
ctx: AgentContext,
|
ctx: ThreadContext,
|
||||||
provider: LlmProvider,
|
runtime: WorkflowRuntime,
|
||||||
logger: LogFn,
|
logger: LogFn,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const reactor = createThreadReactor<null>({
|
const input = buildExtractionInput(ctx);
|
||||||
llm: createLlmFn(provider),
|
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
|
||||||
maxRounds: 2,
|
|
||||||
staticTools: [],
|
|
||||||
structuredToolFromSchema: (schema) => {
|
|
||||||
const jsonSchema = z.toJSONSchema(schema);
|
|
||||||
return {
|
|
||||||
name: "set_workspace",
|
|
||||||
tool: {
|
|
||||||
type: "function" as const,
|
|
||||||
function: {
|
|
||||||
name: "set_workspace",
|
|
||||||
description: "Set the extracted workspace path",
|
|
||||||
parameters: jsonSchema as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
systemPromptForStructuredTool: EXTRACT_SYSTEM_FN,
|
|
||||||
toolHandler: async () => "unknown tool",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await reactor({
|
const result = await runtime.extract(workspaceSchema, contentHash);
|
||||||
thread: null,
|
const workspace = result.meta.workspace.trim();
|
||||||
input: buildExtractionInput(ctx),
|
|
||||||
schema: workspaceSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = result.value.workspace.trim();
|
|
||||||
if (!workspace.startsWith("/")) {
|
if (!workspace.startsWith("/")) {
|
||||||
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
import { createLogger, type LogFn } from "@uncaged/workflow-util";
|
||||||
import {
|
import {
|
||||||
buildThreadInput,
|
buildThreadInput,
|
||||||
createTextAdapter,
|
createAgentAdapter,
|
||||||
type SpawnCliError,
|
type SpawnCliError,
|
||||||
spawnCli,
|
spawnCli,
|
||||||
} from "@uncaged/workflow-util-agent";
|
} from "@uncaged/workflow-util-agent";
|
||||||
@@ -33,36 +33,15 @@ function resolveCursorModel(model: string | null): string {
|
|||||||
return model === null ? "auto" : model;
|
return model === null ? "auto" : model;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
|
type CursorAgentOpt = { prompt: string; workspace: string };
|
||||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
|
||||||
const modelFlag = resolveCursorModel(config.model);
|
|
||||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
|
||||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
|
||||||
|
|
||||||
return createTextAdapter(async (ctx, prompt) => {
|
|
||||||
const validated = validateCursorAgentConfig(config);
|
|
||||||
if (!validated.ok) {
|
|
||||||
throw new Error(validated.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspace: string;
|
|
||||||
|
|
||||||
if (config.workspace !== null) {
|
|
||||||
workspace = config.workspace;
|
|
||||||
} else {
|
|
||||||
if (config.llmProvider === null) {
|
|
||||||
throw new Error("cursor-agent: llmProvider is required when workspace is null");
|
|
||||||
}
|
|
||||||
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
|
|
||||||
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
|
|
||||||
if (extracted === null) {
|
|
||||||
throw new Error(
|
|
||||||
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
workspace = extracted;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function createCursorAgentFn(
|
||||||
|
config: CursorAgentConfig,
|
||||||
|
modelFlag: string,
|
||||||
|
timeoutMs: number | null,
|
||||||
|
logger: LogFn,
|
||||||
|
): AgentFn<CursorAgentOpt> {
|
||||||
|
return async (ctx, { prompt, workspace }) => {
|
||||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||||
const threadInput = await buildThreadInput(ctx);
|
const threadInput = await buildThreadInput(ctx);
|
||||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||||
@@ -86,5 +65,33 @@ export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
|||||||
throwCursorSpawnError(run.error);
|
throwCursorSpawnError(run.error);
|
||||||
}
|
}
|
||||||
return run.value;
|
return run.value;
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
|
||||||
|
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||||
|
const modelFlag = resolveCursorModel(config.model);
|
||||||
|
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||||
|
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
|
return createAgentAdapter(
|
||||||
|
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
|
||||||
|
async (ctx, prompt, runtime: WorkflowRuntime) => {
|
||||||
|
const validated = validateCursorAgentConfig(config);
|
||||||
|
if (!validated.ok) {
|
||||||
|
throw new Error(validated.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace =
|
||||||
|
config.workspace !== null
|
||||||
|
? config.workspace
|
||||||
|
: await extractWorkspacePath(ctx, runtime, logger);
|
||||||
|
if (workspace === null) {
|
||||||
|
throw new Error(
|
||||||
|
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { prompt, workspace };
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
|
||||||
|
|
||||||
export type CursorAgentConfig = {
|
export type CursorAgentConfig = {
|
||||||
/** Absolute path to the cursor-agent CLI binary. */
|
/** Absolute path to the cursor-agent CLI binary. */
|
||||||
command: string;
|
command: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
|
/**
|
||||||
|
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
|
||||||
|
* from the thread via runtime extraction.
|
||||||
|
*/
|
||||||
workspace: string | null;
|
workspace: string | null;
|
||||||
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
|
|
||||||
llmProvider: LlmProvider | null;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,14 +8,11 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
|
|||||||
if (!isAbsolute(config.command)) {
|
if (!isAbsolute(config.command)) {
|
||||||
return err("command must be an absolute path to the cursor-agent CLI binary");
|
return err("command must be an absolute path to the cursor-agent CLI binary");
|
||||||
}
|
}
|
||||||
if (config.workspace !== null && config.workspace.length === 0) {
|
|
||||||
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
|
|
||||||
}
|
|
||||||
if (config.workspace === null && config.llmProvider === null) {
|
|
||||||
return err("llmProvider is required when workspace is null (needed for workspace extraction)");
|
|
||||||
}
|
|
||||||
if (config.timeout < 0) {
|
if (config.timeout < 0) {
|
||||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||||
}
|
}
|
||||||
|
if (config.workspace !== null && !isAbsolute(config.workspace)) {
|
||||||
|
return err("workspace must be an absolute filesystem path when set");
|
||||||
|
}
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
"references": [
|
||||||
|
{ "path": "../workflow-cas" },
|
||||||
|
{ "path": "../workflow-runtime" },
|
||||||
|
{ "path": "../workflow-util-agent" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||||
|
import { createDocxDiffAgent } from "../src/agent.js";
|
||||||
|
|
||||||
|
describe("createDocxDiffAgent", () => {
|
||||||
|
test("returns an AdapterFn (function)", () => {
|
||||||
|
const agent = createDocxDiffAgent({ command: null });
|
||||||
|
expect(typeof agent).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AdapterFn returns a RoleFn (function)", () => {
|
||||||
|
const agent = createDocxDiffAgent({ command: null });
|
||||||
|
const roleFn = agent("", expect.anything() as never);
|
||||||
|
expect(typeof roleFn).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("packageDescriptor", () => {
|
||||||
|
test("has correct name", () => {
|
||||||
|
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { describe, expect, mock, test } from "bun:test";
|
||||||
|
import { ok, err } from "@uncaged/workflow-util";
|
||||||
|
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
|
||||||
|
import { runDocxDiff } from "../src/runner.js";
|
||||||
|
|
||||||
|
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
|
||||||
|
|
||||||
|
function makeSpawn(result: MockSpawnResult) {
|
||||||
|
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempDir(): string {
|
||||||
|
const dir = join(tmpdir(), `diff-test-${Date.now()}`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runDocxDiff", () => {
|
||||||
|
test("exit 0: success, returns DifferMeta JSON", async () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const sourceDocx = join(dir, "original.docx");
|
||||||
|
const modifiedDocx = join(dir, "modified.docx");
|
||||||
|
const diffDocx = join(dir, "diff.docx");
|
||||||
|
writeFileSync(sourceDocx, "");
|
||||||
|
writeFileSync(modifiedDocx, "");
|
||||||
|
|
||||||
|
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||||
|
// simulate docx-diff creating the diff file
|
||||||
|
writeFileSync(diffDocx, "");
|
||||||
|
|
||||||
|
const raw = await runDocxDiff(
|
||||||
|
{ command: "docx-diff" },
|
||||||
|
sourceDocx,
|
||||||
|
modifiedDocx,
|
||||||
|
diffDocx,
|
||||||
|
spawnFn,
|
||||||
|
);
|
||||||
|
const meta = JSON.parse(raw);
|
||||||
|
expect(meta.sourceDocx).toBe(sourceDocx);
|
||||||
|
expect(meta.modifiedDocx).toBe(modifiedDocx);
|
||||||
|
expect(meta.diffDocx).toBe(diffDocx);
|
||||||
|
|
||||||
|
expect(spawnFn.mock.calls[0][1]).toEqual([
|
||||||
|
sourceDocx,
|
||||||
|
modifiedDocx,
|
||||||
|
"--output",
|
||||||
|
"docx",
|
||||||
|
"--out-file",
|
||||||
|
diffDocx,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exit 1 (changes found): treated as success", async () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const sourceDocx = join(dir, "s.docx");
|
||||||
|
const modifiedDocx = join(dir, "m.docx");
|
||||||
|
const diffDocx = join(dir, "diff.docx");
|
||||||
|
writeFileSync(sourceDocx, "");
|
||||||
|
writeFileSync(modifiedDocx, "");
|
||||||
|
writeFileSync(diffDocx, "");
|
||||||
|
|
||||||
|
const spawnFn = makeSpawn(
|
||||||
|
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exit 2: throws error", async () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const spawnFn = makeSpawn(
|
||||||
|
err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
|
||||||
|
).rejects.toThrow("docx-diff failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("timeout: throws error", async () => {
|
||||||
|
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
|
||||||
|
).rejects.toThrow("timed out");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when diff file not created", async () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||||
|
// do NOT create diffDocx
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn),
|
||||||
|
).rejects.toThrow("diff file not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses PATH docx-diff when command is null", async () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const diffDocx = join(dir, "diff.docx");
|
||||||
|
writeFileSync(diffDocx, "");
|
||||||
|
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||||
|
|
||||||
|
await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn);
|
||||||
|
|
||||||
|
expect(spawnFn.mock.calls[0][0]).toBe("docx-diff");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/workflow-agent-docx-diff",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": ["src", "dist", "package.json"],
|
||||||
|
"type": "module",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
|
"@uncaged/workflow-util-agent": "workspace:^",
|
||||||
|
"@uncaged/workflow-template-document": "workspace:^",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@uncaged/workflow-util": "workspace:^"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as z from "zod/v4";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||||
|
import type { WriterMeta } from "@uncaged/workflow-template-document";
|
||||||
|
import { runDocxDiff } from "./runner.js";
|
||||||
|
import type { DocxDiffAgentConfig } from "./types.js";
|
||||||
|
|
||||||
|
export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
|
||||||
|
return <T>(_prompt: string, schema: z.ZodType<T>) =>
|
||||||
|
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||||
|
const writerStep = ctx.steps.find((s) => s.role === "writer");
|
||||||
|
if (writerStep === undefined) throw new Error("differ: no writer step found");
|
||||||
|
|
||||||
|
const writerMeta = writerStep.meta as WriterMeta;
|
||||||
|
if (writerMeta.mode !== "edit")
|
||||||
|
throw new Error("differ: writer did not run in edit mode");
|
||||||
|
|
||||||
|
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
|
||||||
|
const raw = await runDocxDiff(
|
||||||
|
config,
|
||||||
|
writerMeta.sourceDocx,
|
||||||
|
writerMeta.outputDocx,
|
||||||
|
diffDocx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||||
|
return { meta, childThread: null };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { createDocxDiffAgent } from "./agent.js";
|
||||||
|
export { packageDescriptor } from "./package-descriptor.js";
|
||||||
|
export type { DocxDiffAgentConfig } from "./types.js";
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
|
export const packageDescriptor: PackageDescriptor = {
|
||||||
|
name: "@uncaged/workflow-agent-docx-diff",
|
||||||
|
version: "0.1.0",
|
||||||
|
capabilities: ["docx-diff-cli", "docx-diff-report"],
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: {
|
||||||
|
anyOf: [{ type: "string" }, { type: "null" }],
|
||||||
|
description: "Path to docx-diff CLI binary; null uses PATH.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||||
|
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
||||||
|
import type { DocxDiffAgentConfig } from "./types.js";
|
||||||
|
|
||||||
|
type SpawnCliFn = typeof spawnCli;
|
||||||
|
|
||||||
|
function throwSpawnError(e: SpawnCliError): never {
|
||||||
|
if (e.kind === "non_zero_exit")
|
||||||
|
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
|
||||||
|
if (e.kind === "timeout")
|
||||||
|
throw new Error("docx-diff: timed out");
|
||||||
|
throw new Error(`docx-diff: spawn failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDocxDiff(
|
||||||
|
config: DocxDiffAgentConfig,
|
||||||
|
sourceDocx: string,
|
||||||
|
modifiedDocx: string,
|
||||||
|
diffDocx: string,
|
||||||
|
spawnCliFn: SpawnCliFn = spawnCli,
|
||||||
|
): Promise<string> {
|
||||||
|
const command = config.command ?? "docx-diff";
|
||||||
|
const result = await spawnCliFn(
|
||||||
|
command,
|
||||||
|
[sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx],
|
||||||
|
{ cwd: null, timeoutMs: null },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const e = result.error;
|
||||||
|
// exit 1 = changes found (normal for docx-diff)
|
||||||
|
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
|
||||||
|
// fall through to file check
|
||||||
|
} else {
|
||||||
|
throwSpawnError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(diffDocx);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`docx-diff: diff file not found: ${diffDocx}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx });
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type DocxDiffAgentConfig = {
|
||||||
|
command: string | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../workflow-protocol" },
|
||||||
|
{ "path": "../workflow-runtime" },
|
||||||
|
{ "path": "../workflow-util-agent" },
|
||||||
|
{ "path": "../workflow-template-document" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# @uncaged/workflow-agent-hermes
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||||
|
|
||||||
|
## 0.4.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.4.5
|
||||||
|
- @uncaged/workflow-util-agent@0.4.5
|
||||||
|
|
||||||
|
## 0.4.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.4.4
|
||||||
|
- @uncaged/workflow-util-agent@0.4.4
|
||||||
|
|
||||||
|
## 0.4.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-runtime@0.4.3
|
||||||
|
- @uncaged/workflow-util-agent@0.4.3
|
||||||
|
|
||||||
|
## 0.4.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-runtime@0.4.2
|
||||||
|
- @uncaged/workflow-util-agent@0.4.2
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Fix package exports for published packages and adopt changesets for version management.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-runtime@0.4.0
|
||||||
|
- @uncaged/workflow-util-agent@0.4.0
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-hermes",
|
"name": "@uncaged/workflow-agent-hermes",
|
||||||
"version": "0.3.21",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-runtime": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*"
|
"@uncaged/workflow-util-agent": "workspace:^"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user