Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4786a247ac | |||
| 6802aecb2e | |||
| e65e2aec72 | |||
| 008701ef46 | |||
| f6298c73bf | |||
| fa188ddf21 | |||
| 61ee22f647 | |||
| dbfed616f8 | |||
| f493b251db | |||
| b699200adf | |||
| 7b13e7deb4 | |||
| b1d9eebcf7 | |||
| 6b201fd73e | |||
| f67507bb32 | |||
| 00f95547d9 | |||
| f79db334a0 | |||
| 8e7aa3362a | |||
| 10b478640d | |||
| b0ef9c55a9 | |||
| a335471cc7 | |||
| 7a0c928a4a | |||
| d8181e9fdf | |||
| ef0174a6f1 | |||
| 2a72dcde20 | |||
| b1759096a2 | |||
| f8c06ada64 | |||
| 806edb2750 | |||
| da1678ffef | |||
| 88c251fc14 | |||
| 9fb817a99c | |||
| f9b8cf025e | |||
| d10f55294a | |||
| 0ece23f03e | |||
| 389924c3ab | |||
| 0dfa20f1d7 | |||
| 0fcab06b80 | |||
| 15dcdee1cb | |||
| 53fa4d8972 | |||
| ff8542d811 | |||
| 97637ad831 | |||
| 27d699fa73 | |||
| 80bbb8b5f9 | |||
| d310d43ab8 | |||
| 7612c97ae7 | |||
| 8ffea10db0 | |||
| abe516f739 | |||
| 647f40bdd5 | |||
| 2265b32933 | |||
| 3971c26dc1 | |||
| 27d6062992 | |||
| 625d975c3d | |||
| b4919aa921 | |||
| 3a927de63f | |||
| 512a3f8653 | |||
| 0b20e88317 | |||
| abc9dcfc5a | |||
| 080b37c2be | |||
| 7935b73374 | |||
| cfa890f83c | |||
| 81c08ac7e2 | |||
| fdcfcc7eba | |||
| 4972f99ca0 | |||
| 48bf701281 | |||
| d8cba5eea0 | |||
| d9f7648fdd | |||
| a2e9dd9785 | |||
| 3b498069b6 | |||
| 984d93a6f5 | |||
| 2274de29c3 | |||
| 911cbf2a8a | |||
| 09a5da2df2 | |||
| e4c228d36e | |||
| f8de0e913b | |||
| cb97507e9a | |||
| 4b442bb251 | |||
| ac53128ff7 | |||
| 607366c469 | |||
| 577fb27470 | |||
| 5475dd3f5c | |||
| 09b7ddf6d0 | |||
| c4e94bbe56 | |||
| dbefe793f2 | |||
| 6483bc4861 | |||
| fecb02b115 | |||
| 87938c1886 | |||
| 95a130136b | |||
| aba5642908 | |||
| 168e604602 | |||
| d50159c5a7 | |||
| 9a7ad34e55 | |||
| 4193157124 | |||
| 6ff1414cf0 | |||
| 37f4203b40 | |||
| c4ec22bb4f | |||
| 427f47d72c | |||
| 9f25745e1e | |||
| 82247c86ce | |||
| 0ef2d8fec2 | |||
| aa14fd08e0 | |||
| e43d4f3bbf | |||
| b0c73b5439 | |||
| bbbe4651c2 | |||
| 7dfe0eb6a9 | |||
| 47a4268b9b | |||
| 0c90b88e08 | |||
| 5583a9da00 | |||
| 4a0cb7c615 | |||
| fa97a7c92a | |||
| 0097633a3b | |||
| 04591296b2 | |||
| 96039dbbbf | |||
| 5230462b8d | |||
| 4a39d3fdef | |||
| 4de13cea44 | |||
| d9d542c570 | |||
| cf6115517c | |||
| 108f134020 | |||
| 8123399189 | |||
| 6324122168 | |||
| 25b411f22e | |||
| 54dc8fcb39 | |||
| a40e1bb847 | |||
| 2c8bcf7996 | |||
| af2a25bf87 | |||
| 0abc8bcb3e | |||
| 524e00a0a6 | |||
| eba3c70e76 | |||
| e2d60fa72e | |||
| dfae96ad45 | |||
| 2f4473f22c | |||
| ca223a19c6 | |||
| 0779ab85ca | |||
| 4d85a2eebb | |||
| cef4617956 | |||
| 813cbfd5c2 | |||
| a11d76264a | |||
| 6e8dedeb2f | |||
| 762c457978 | |||
| 9c26285424 | |||
| 45f479e60f | |||
| 3fca67e443 | |||
| 9b2460633c | |||
| dfb6fda06d | |||
| 827ff13c4a | |||
| 7a19ceca89 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@uncaged/workflow-util": patch
|
||||
---
|
||||
|
||||
Replace optionalEnv/requireEnv with unified env(name, fallback) API
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: correct internal dependency versions for prerelease
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@uncaged/workflow-util-agent": patch
|
||||
---
|
||||
|
||||
fix: include create-agent-adapter.ts in published src
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"mode": "pre",
|
||||
"mode": "exit",
|
||||
"tag": "alpha",
|
||||
"initialVersions": {
|
||||
"@uncaged/cli-workflow": "0.4.5",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": minor
|
||||
---
|
||||
|
||||
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||
@@ -1,27 +1,3 @@
|
||||
---
|
||||
description: Ban dynamic import() in production code — use static imports instead
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
# No Dynamic Import
|
||||
|
||||
# No Dynamic Import in Production Code
|
||||
|
||||
## Rule
|
||||
|
||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||
Always use static top-level `import` statements.
|
||||
|
||||
## Exception (must include a comment explaining why)
|
||||
|
||||
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
See [docs/no-dynamic-import.md](../../docs/no-dynamic-import.md) for full rules.
|
||||
|
||||
@@ -1,67 +1,3 @@
|
||||
# Sync README
|
||||
# Sync Readme
|
||||
|
||||
When updating README.md files in this monorepo, follow these conventions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Root `README.md` — project overview and navigation hub
|
||||
- Per-package `packages/*/README.md` — each package self-contained
|
||||
|
||||
## Root README Structure
|
||||
|
||||
The root README should have these sections in order:
|
||||
|
||||
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
|
||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||
3. **Architecture** — dependency layer diagram (text-based)
|
||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
|
||||
5. **Quick Start** — install, build, register workflow, start thread, run step
|
||||
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
|
||||
7. **Development** — bun install / build / check / test
|
||||
|
||||
## Per-Package README Structure
|
||||
|
||||
Each package README should have:
|
||||
|
||||
1. **Title** — package name
|
||||
2. **One-line description** — matching package.json
|
||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
|
||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||
6. **CLI Usage** (cli/agent packages) — command reference with examples
|
||||
7. **Internal Structure** — brief src/ file organization
|
||||
8. **Configuration** (if applicable)
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Gather current state
|
||||
For each package read:
|
||||
- package.json (name, version, description, dependencies, bin)
|
||||
- src/index.ts (public API exports)
|
||||
- Existing README.md (preserve hand-written content worth keeping)
|
||||
|
||||
### Step 2: Update root README
|
||||
- Ensure ALL packages in packages/ directory are listed in the table
|
||||
- Update CLI command reference from uwf --help output
|
||||
- Keep Quick Start examples valid
|
||||
|
||||
### Step 3: Write/update each package README
|
||||
- Follow the per-package structure
|
||||
- API section MUST match actual src/index.ts exports — never invent
|
||||
- For agent packages: document CLI binary name, how it is invoked
|
||||
- For lib packages: document exported types and functions
|
||||
- Internal structure: list actual files in src/
|
||||
|
||||
### Step 4: Verify
|
||||
- All relative links work
|
||||
- Package names match package.json
|
||||
- No references to removed/renamed packages
|
||||
- bun run build still passes
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only document what src/index.ts actually exports
|
||||
- Root README summarizes, package READMEs go into detail
|
||||
- Verify CLI examples against actual commands
|
||||
- Preserve existing good prose when updating
|
||||
- English for all README content
|
||||
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- run: bun install
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Lint
|
||||
run: bun run check
|
||||
|
||||
- name: Test
|
||||
run: bun run test:ci
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or unexpected behavior
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
|
||||
A clear description of what the bug is.
|
||||
|
||||
## To reproduce
|
||||
|
||||
Steps or commands to reproduce:
|
||||
|
||||
```bash
|
||||
uwf ...
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Actual behavior
|
||||
|
||||
What actually happened. Include error messages or logs.
|
||||
|
||||
## Environment
|
||||
|
||||
- OS:
|
||||
- Bun version:
|
||||
- uwf version (`uwf --version`):
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## What
|
||||
|
||||
Describe the feature or improvement.
|
||||
|
||||
## Why
|
||||
|
||||
Why is this needed? What problem does it solve?
|
||||
|
||||
## Proposed solution
|
||||
|
||||
How should it work? Include API sketches, CLI examples, or workflow YAML snippets if applicable.
|
||||
@@ -0,0 +1,15 @@
|
||||
## What
|
||||
|
||||
What this PR does.
|
||||
|
||||
## Why
|
||||
|
||||
Why the change is needed.
|
||||
|
||||
## Changes
|
||||
|
||||
- `path/to/file` — what changed and why
|
||||
|
||||
## Ref
|
||||
|
||||
Fixes #
|
||||
@@ -0,0 +1,28 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Lint
|
||||
run: bunx biome check .
|
||||
|
||||
- name: Test
|
||||
run: bun run test:ci
|
||||
+2
-1
@@ -12,4 +12,5 @@ packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
*.py
|
||||
.claude
|
||||
tmp
|
||||
tmp.worktrees/
|
||||
.worktrees/
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# Test Spec: uwf setup model connectivity validation (#335)
|
||||
|
||||
## Context
|
||||
|
||||
File: `packages/cli-workflow/src/commands/setup.ts`
|
||||
Test file: `packages/cli-workflow/src/__tests__/setup-validate.test.ts`
|
||||
|
||||
After `cmdSetup` writes config, it should send a test chat completion request to verify the configured model is reachable. If validation fails, warn the user (don't abort — config is already saved).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Add a `validateModel(baseUrl, apiKey, model)` function that sends a minimal chat completion request (`POST /chat/completions` with `messages: [{role:"user",content:"hi"}]`, `max_tokens: 1`)
|
||||
- Returns `Result<void, string>` — ok if 2xx response, error with reason string otherwise
|
||||
- Use `AbortSignal.timeout(15_000)` for the request
|
||||
- Both `cmdSetup` and `cmdSetupInteractive` should call it after saving config
|
||||
- `cmdSetup` returns validation result in its return object: `{ ...existing, validation: { ok: true } | { ok: false, error: string } }`
|
||||
- `cmdSetupInteractive` prints a warning to console if validation fails, success message if it passes
|
||||
- Use the project logger (`createLogger`) — no raw `console.log` except in interactive CLI output (per CLAUDE.md)
|
||||
|
||||
## Test Cases (vitest)
|
||||
|
||||
### 1. `validateModel` — success path
|
||||
- Mock `fetch` to return `{ status: 200, ok: true, json: () => ({}) }`
|
||||
- Call `validateModel(baseUrl, apiKey, model)`
|
||||
- Assert returns `{ ok: true, value: undefined }`
|
||||
- Assert fetch was called with correct URL (`${baseUrl}/chat/completions`), correct headers (`Authorization: Bearer ${apiKey}`), correct body (model, messages, max_tokens: 1)
|
||||
|
||||
### 2. `validateModel` — HTTP error (401 unauthorized)
|
||||
- Mock `fetch` to return `{ status: 401, ok: false, statusText: "Unauthorized" }`
|
||||
- Call `validateModel(baseUrl, apiKey, model)`
|
||||
- Assert returns `{ ok: false, error: <string containing "401"> }`
|
||||
|
||||
### 3. `validateModel` — HTTP error (404 model not found)
|
||||
- Mock `fetch` to return `{ status: 404, ok: false, statusText: "Not Found" }`
|
||||
- Assert returns `{ ok: false, error: <string containing "404"> }`
|
||||
|
||||
### 4. `validateModel` — network timeout
|
||||
- Mock `fetch` to throw `DOMException` with name `AbortError`
|
||||
- Assert returns `{ ok: false, error: <string containing "timeout" or "unreachable"> }`
|
||||
|
||||
### 5. `validateModel` — network error (DNS failure, connection refused)
|
||||
- Mock `fetch` to throw `TypeError("fetch failed")`
|
||||
- Assert returns `{ ok: false, error: <string mentioning connectivity> }`
|
||||
|
||||
### 6. `cmdSetup` — includes validation result on success
|
||||
- Mock global `fetch` for `/chat/completions` to succeed
|
||||
- Call `cmdSetup({ provider, baseUrl, apiKey, model, storageRoot })`
|
||||
- Assert returned object has `validation: { ok: true, value: undefined }`
|
||||
- Assert config files are still written (existing behavior preserved)
|
||||
|
||||
### 7. `cmdSetup` — includes validation result on failure (config still saved)
|
||||
- Mock global `fetch` for `/chat/completions` to return 401
|
||||
- Call `cmdSetup({ ... })`
|
||||
- Assert returned object has `validation: { ok: false, error: ... }`
|
||||
- Assert `config.yaml` and `.env` are still written (validation failure doesn't prevent saving)
|
||||
|
||||
### 8. `cmdSetupInteractive` — prints success message on validation pass
|
||||
- Mock `fetch` for both `/models` and `/chat/completions` to succeed
|
||||
- Mock stdin to provide valid selections
|
||||
- Capture console output
|
||||
- Assert output contains a success message like "Model verified" or "✓"
|
||||
|
||||
### 9. `cmdSetupInteractive` — prints warning on validation failure
|
||||
- Mock `fetch`: `/models` succeeds, `/chat/completions` returns 401
|
||||
- Mock stdin for valid selections
|
||||
- Capture console output
|
||||
- Assert output contains a warning about model not being reachable and suggests trying a different model
|
||||
|
||||
### 10. `validateModel` — request body correctness
|
||||
- Mock `fetch` to capture the request body
|
||||
- Call `validateModel(baseUrl, apiKey, "test-model")`
|
||||
- Assert body is `{ model: "test-model", messages: [{role: "user", content: "hi"}], max_tokens: 1 }`
|
||||
|
||||
## Export Requirements
|
||||
|
||||
- `validateModel` must be exported (for direct unit testing)
|
||||
- Signature: `async function validateModel(baseUrl: string, apiKey: string, model: string): Promise<Result<void, string>>`
|
||||
- `Result` type: `{ ok: true; value: T } | { ok: false; error: E }` (project convention)
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
- **New**: `packages/cli-workflow/src/__tests__/setup-validate.test.ts` — all test cases above
|
||||
- **Modify**: `packages/cli-workflow/src/commands/setup.ts` — add `validateModel`, integrate into `cmdSetup` and `cmdSetupInteractive`
|
||||
@@ -0,0 +1,285 @@
|
||||
name: "e2e-walkthrough"
|
||||
description: "End-to-end walkthrough of uwf CLI. Dogfooding: uwf tests uwf. Each role validates a phase of the CLI surface inside an isolated Docker container."
|
||||
roles:
|
||||
bootstrap:
|
||||
description: "Start Docker container with isolated storage, verify uwf is runnable"
|
||||
goal: "You are an E2E test runner. Set up an isolated Docker environment and verify basic uwf functionality."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
1. Start a Docker container with isolated storage.
|
||||
IMPORTANT: Mount the source code READ-ONLY to prevent the container
|
||||
from overwriting host files (e.g. bun install would replace macOS bun with Linux bun).
|
||||
Use a container-local HOME so bun/npm installs stay inside the container.
|
||||
Add host.docker.internal mapping for LLM API access from inside the container.
|
||||
```
|
||||
docker run -d --name uwf-e2e-$$ \
|
||||
-v "$(pwd):/workspace:ro" \
|
||||
-e HOME=/root \
|
||||
-e UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-w /workspace \
|
||||
node:22-bookworm \
|
||||
sleep infinity
|
||||
```
|
||||
NOTE: Run this from the workflow monorepo root directory.
|
||||
On macOS Docker Desktop, host.docker.internal is already available;
|
||||
--add-host ensures it also works on Linux Docker.
|
||||
|
||||
2. Inside the container, copy source to a writable location, install bun, install deps,
|
||||
then `bun link` all packages so that `uwf`, `uwf-hermes`, `uwf-builtin` are on PATH:
|
||||
```
|
||||
docker exec uwf-e2e-$$ bash -c '
|
||||
# Copy source to writable location (mount is read-only)
|
||||
cp -r /workspace /root/workflow
|
||||
|
||||
# Install bun
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
|
||||
# Isolated storage
|
||||
mkdir -p $UNCAGED_WORKFLOW_STORAGE_ROOT
|
||||
|
||||
# Install workspace deps
|
||||
cd /root/workflow && bun install
|
||||
|
||||
# bun link each package that has a bin entry
|
||||
cd packages/cli-workflow && bun link && cd ../..
|
||||
cd packages/workflow-agent-hermes && bun link && cd ../..
|
||||
cd packages/workflow-agent-builtin && bun link && cd ../..
|
||||
'
|
||||
```
|
||||
3. Verify all three commands are available inside the container:
|
||||
```
|
||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf --version'
|
||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-hermes --help'
|
||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-builtin --help'
|
||||
```
|
||||
4. Copy host uwf config into the container's isolated storage.
|
||||
The host config contains provider credentials and model settings needed for LLM calls.
|
||||
Also rewrite any localhost URLs to host.docker.internal so the container can reach host services.
|
||||
```
|
||||
docker cp ~/.uncaged/workflow/config.yaml uwf-e2e-$$:/tmp/uwf-e2e-storage/config.yaml 2>/dev/null || true
|
||||
docker exec uwf-e2e-$$ bash -c '
|
||||
if [ -f $UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml ]; then
|
||||
sed -i "s|localhost|host.docker.internal|g; s|127\.0\.0\.1|host.docker.internal|g" \
|
||||
$UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml
|
||||
fi
|
||||
'
|
||||
```
|
||||
|
||||
Report the container name and confirm uwf + agents are working.
|
||||
Set containerName to the Docker container name for subsequent roles.
|
||||
output: "Report uwf version and container readiness. Set $status to pass with containerName, or fail with error."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
containerName: { type: string }
|
||||
required: [$status, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
config-and-registry:
|
||||
description: "Validate uwf config commands and workflow registration"
|
||||
goal: "You are an E2E test runner. Validate uwf config operations and workflow registration inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use the container from the previous step (containerName is in your prompt).
|
||||
All commands run via: `docker exec <containerName> bash -c '...'`
|
||||
All commands use `uwf` (installed via `bun link` inside the container).
|
||||
Remember to set env vars in each exec:
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
export UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
Config tests:
|
||||
1. `uwf config list` — verify it returns valid JSON
|
||||
2. `uwf config set models.test.name test-model` — set a test key
|
||||
3. `uwf config get models.test.name` — verify it returns "test-model"
|
||||
|
||||
Workflow registration tests:
|
||||
4. `uwf workflow add /root/workflow/examples/debate.yaml` — register a workflow (use debate.yaml as it has no $SUSPEND dependency)
|
||||
5. Verify the output contains a hash
|
||||
6. `uwf workflow list` — verify non-empty array
|
||||
7. Capture the workflow name from the list
|
||||
8. `uwf workflow show <name>` — verify it returns roles
|
||||
|
||||
Report all test results with pass/fail counts.
|
||||
output: "Report test results. Set $status to pass (with workflowName and containerName) or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
workflowName: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, workflowName, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
thread-ops:
|
||||
description: "Test thread start, list, show, and exec"
|
||||
goal: "You are an E2E test runner. Validate thread creation and execution inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use the container (containerName) and workflow (workflowName) from your prompt.
|
||||
All commands via: `docker exec <containerName> bash -c '...'`
|
||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
1. `uwf thread start <workflowName> -p 'E2E test: what is 2+2?'` — capture thread ID from JSON output
|
||||
2. `uwf thread list` — verify the thread appears in the list
|
||||
3. `uwf thread show <threadId>` — verify head pointer exists
|
||||
4. `uwf thread exec <threadId> --agent uwf-builtin` — execute one step
|
||||
5. Verify exec returns JSON with a head field
|
||||
|
||||
Report results. Pass threadId and containerName forward.
|
||||
output: "Report test results. Set $status to pass (with threadId, workflowName, containerName) or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
threadId: { type: string }
|
||||
workflowName: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, threadId, workflowName, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
inspect:
|
||||
description: "Test step list/show, thread read, and CAS operations"
|
||||
goal: "You are an E2E test runner. Validate read and inspect operations inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use the container (containerName) and threadId from your prompt.
|
||||
All commands via: `docker exec <containerName> bash -c '...'`
|
||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
Step inspection:
|
||||
1. `uwf step list <threadId>` — verify steps array has length > 1
|
||||
2. Capture the last step hash from the output
|
||||
3. `uwf step show <lastStepHash>` — verify it returns a role field
|
||||
|
||||
Thread read:
|
||||
4. `uwf thread read <threadId>` — verify non-empty output
|
||||
|
||||
CAS operations:
|
||||
5. `uwf cas get <lastStepHash>` — verify returns a type field
|
||||
6. `uwf cas has <lastStepHash>` — verify exits 0
|
||||
7. `uwf cas refs <lastStepHash>` — list refs (may be empty)
|
||||
8. `uwf cas walk <lastStepHash>` — verify returns non-empty array
|
||||
|
||||
Report results. Pass threadId, lastStepHash, workflowName, containerName forward.
|
||||
output: "Report test results. Set $status to pass (with threadId, lastStepHash, workflowName, containerName) or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
threadId: { type: string }
|
||||
lastStepHash: { type: string }
|
||||
workflowName: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, threadId, lastStepHash, workflowName, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
cancel-and-fork:
|
||||
description: "Test thread cancel, step fork, and log inspection"
|
||||
goal: "You are an E2E test runner. Validate cancel, fork, and log operations inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use containerName, threadId, lastStepHash, and workflowName from your prompt.
|
||||
All commands via: `docker exec <containerName> bash -c '...'`
|
||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
Cancel:
|
||||
1. Start a second thread: `uwf thread start <workflowName> -p 'E2E cancel test'`
|
||||
2. Cancel it: `uwf thread cancel <secondThreadId>`
|
||||
3. Verify it appears in cancelled list: `uwf thread list --status cancelled`
|
||||
|
||||
Fork:
|
||||
4. Fork from the first thread's last step: `uwf step fork <lastStepHash>`
|
||||
5. Verify fork creates a new thread with a different ID
|
||||
|
||||
Logs:
|
||||
6. `uwf log list` — verify output (may be empty)
|
||||
7. `uwf log show --thread <threadId>` — verify runs without error
|
||||
|
||||
Report results with summary.
|
||||
output: "Report test results with summary. Set $status to pass or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
containerName: { type: string }
|
||||
summary: { type: string }
|
||||
required: [$status, containerName, summary]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
cleanup:
|
||||
description: "Remove Docker container"
|
||||
goal: "You are an E2E test runner. Clean up the Docker container used for testing."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Remove the Docker container (containerName is in your prompt):
|
||||
1. `docker rm -f <containerName>`
|
||||
2. Verify the container is gone: `docker ps -a --filter name=<containerName> --format '{{.Names}}'` should return empty
|
||||
|
||||
Report cleanup result.
|
||||
output: "Report cleanup result. Set $status to pass or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
summary: { type: string }
|
||||
required: [$status, summary]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "bootstrap", prompt: "Set up the Docker container and verify uwf is runnable." }
|
||||
bootstrap:
|
||||
pass: { role: "config-and-registry", prompt: "Container {{{containerName}}} is ready. Validate config and workflow registration." }
|
||||
fail: { role: "$END", prompt: "Bootstrap failed: {{{error}}}. No container was created." }
|
||||
config-and-registry:
|
||||
pass: { role: "thread-ops", prompt: "Config and registry OK. Workflow '{{{workflowName}}}' registered. Container: {{{containerName}}}. Now test thread operations." }
|
||||
fail: { role: "cleanup", prompt: "Config/registry failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
thread-ops:
|
||||
pass: { role: "inspect", prompt: "Thread ops OK. threadId={{{threadId}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test inspect operations." }
|
||||
fail: { role: "cleanup", prompt: "Thread ops failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
inspect:
|
||||
pass: { role: "cancel-and-fork", prompt: "Inspect OK. threadId={{{threadId}}}, lastStepHash={{{lastStepHash}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test cancel, fork, and logs." }
|
||||
fail: { role: "cleanup", prompt: "Inspect failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
cancel-and-fork:
|
||||
pass: { role: "cleanup", prompt: "All tests passed! {{{summary}}}. Clean up container {{{containerName}}}." }
|
||||
fail: { role: "cleanup", prompt: "Cancel/fork failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
cleanup:
|
||||
pass: { role: "$END", prompt: "E2E walkthrough complete. {{{summary}}}" }
|
||||
fail: { role: "$END", prompt: "Cleanup failed: {{{error}}}. Manual cleanup may be needed." }
|
||||
+158
-87
@@ -10,9 +10,9 @@ roles:
|
||||
procedure: |
|
||||
On first run (no previous steps):
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Read CLAUDE.md (or equivalent project conventions file) to understand coding standards
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output status=insufficient_info and terminate
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
@@ -21,17 +21,27 @@ roles:
|
||||
|
||||
After producing the test spec:
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in frontmatter.plan (required when status=ready)
|
||||
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
|
||||
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||
3. Set repoPath to the absolute path of the repository root
|
||||
|
||||
IMPORTANT: Extract the repo remote (owner/repo) from git:
|
||||
```bash
|
||||
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
|
||||
```
|
||||
Store the result as repoRemote in your frontmatter output so downstream roles can use it for tea/API calls.
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ready, insufficient_info]
|
||||
plan:
|
||||
type: string
|
||||
required: [status]
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, plan, repoPath, repoRemote]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
@@ -39,33 +49,53 @@ roles:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The repo path and other details are provided in your task prompt.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. `cd ~/repos/workflow && git fetch origin` to get latest refs
|
||||
2. First time (no existing branch):
|
||||
- `git worktree add ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
3. If bounced back from reviewer or tester (branch already exists):
|
||||
- The worktree should already exist at `~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
|
||||
1. cd into the repo path provided in your task prompt
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If bounced back from reviewer or tester (branch already exists):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
4. ALL subsequent work must happen inside the worktree directory.
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
||||
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
||||
7. Write tests first based on the spec
|
||||
8. Implement the code to make tests pass
|
||||
9. Ensure `bun run build` passes with no errors
|
||||
10. Run `bun test` to verify all tests pass
|
||||
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
- If tests fail on first run:
|
||||
* Read the test output carefully for missing imports or setup issues
|
||||
* Check if you're running tests from the correct working directory (package root vs workspace root)
|
||||
* Fix the immediate issue and rerun ONCE
|
||||
* If tests still fail after 2 attempts: check the test spec for ambiguities
|
||||
* If stuck after 3 test cycles: set $status=failed with detailed error report rather than continuing blind retries
|
||||
12. MANDATORY VERIFICATION before reporting done:
|
||||
- Run `git branch --show-current` and confirm branch name matches expected
|
||||
- Run `git status` and verify changed files exist
|
||||
- Run `ls -la <key-implementation-files>` to verify they exist on disk
|
||||
- If ANY verification fails: retry the implementation, do NOT report done
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [done, failed]
|
||||
required: [status]
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
@@ -73,9 +103,14 @@ roles:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
CRITICAL: You MUST execute every verification command below. Do NOT report results without running the actual commands. Do NOT rely on prior context or assumptions.
|
||||
|
||||
Before reviewing, verify the worktree and branch exist:
|
||||
0. Run `cd <worktree-path> && pwd` to confirm the path is accessible
|
||||
- If the cd fails: the worktree truly doesn't exist, reject with that reason
|
||||
- If the cd succeeds: proceed with step 1 below
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
|
||||
@@ -85,91 +120,127 @@ roles:
|
||||
4. `bunx biome check` — no lint violations
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against CLAUDE.md conventions):
|
||||
- Functional-first: `function` + `type`, not `class` + `interface`
|
||||
- No optional properties (`?:`) — use `T | null`
|
||||
- Naming conventions (kebab-case files, PascalCase types, camelCase functions)
|
||||
- Module boundary discipline (folder exports via index.ts)
|
||||
- No `console.log` (use structured logger)
|
||||
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||
- Naming conventions, module boundaries, code style
|
||||
- No `console.log` in production code
|
||||
- No dynamic imports in production code
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Frontmatter must include: status (approved or rejected)."
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [approved, rejected]
|
||||
required: [status]
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [passed, fix_code, fix_spec]
|
||||
required: [status]
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
repoRemote: { type: string }
|
||||
worktree: { type: string }
|
||||
branch: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
repoRemote: { type: string }
|
||||
worktree: { type: string }
|
||||
branch: { type: string }
|
||||
required: [$status, report]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||
The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||
1. Check `git status` — if working tree is clean and branch is ahead of origin, skip to step 3 (push).
|
||||
2. If there are unstaged/uncommitted changes: `git add -A` then `git commit -m "type: description\n\nFixes #N"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --repo uncaged/workflow --title "..." --description "..."`
|
||||
- The `--repo` flag is required to work in worktree directories (fixes #474 "path segment [0] is empty" error)
|
||||
- If working on a different repo, extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
- On tea failure: capture stderr/stdout, log the error clearly, include PR details (title, description, branch) for manual creation, and mark success=false
|
||||
5. After PR creation, clean up the worktree:
|
||||
- `cd ~/repos/workflow`
|
||||
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: status (committed or hook_failed)."
|
||||
4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.
|
||||
- If no output or push failed: capture the error, mark hook_failed
|
||||
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):
|
||||
```bash
|
||||
GITEA_TOKEN=$(cfg get GITEA_TOKEN)
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
||||
"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls" \
|
||||
-d '{"title":"...","body":"...","head":"<branch>","base":"main"}'
|
||||
```
|
||||
- The repo remote (owner/repo format, e.g. "uncaged/workflow") is given in your task prompt — use it directly.
|
||||
- PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
6. **Verify PR was created** — parse the curl response JSON: it must contain a `"number"` field. Print the PR URL.
|
||||
- If curl returns an error or no number field: capture the response, mark hook_failed
|
||||
7. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [committed, hook_failed]
|
||||
required: [status]
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
repoRemote: { type: string }
|
||||
worktree: { type: string }
|
||||
branch: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
repoRemote: { type: string }
|
||||
worktree: { type: string }
|
||||
branch: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
planner:
|
||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||
ready: { role: "developer", prompt: "Implement the plan from the planner." }
|
||||
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}." }
|
||||
developer:
|
||||
failed: { role: "$END", prompt: "Development failed; end the workflow." }
|
||||
done: { role: "reviewer", prompt: "Send the implementation to the reviewer." }
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance. Repo remote: {{{repoRemote}}}." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected the implementation; fix the issues." }
|
||||
approved: { role: "tester", prompt: "Review passed; run tests on the implementation." }
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues; return to developer." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues; return to planner." }
|
||||
passed: { role: "committer", prompt: "Tests passed; commit and push the changes." }
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec. Repo remote: {{{repoRemote}}}." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}. Repo remote (owner/repo): {{{repoRemote}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed; return to developer to fix." }
|
||||
committed: { role: "$END", prompt: "Commit succeeded; complete the workflow." }
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# UWF Bootstrap Guide
|
||||
|
||||
This guide helps any AI agent set up `uwf` (Uncaged Workflow) from scratch — or self-check and upgrade an existing installation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **bun** — `uwf` is built with bun. Install: `curl -fsSL https://bun.sh/install | bash`
|
||||
- **Network access** — to install npm packages
|
||||
|
||||
> **Already have uwf?** Jump to [Self-Check & Upgrade](#self-check--upgrade).
|
||||
|
||||
---
|
||||
|
||||
## Fresh Install
|
||||
|
||||
### 1. Install uwf CLI
|
||||
|
||||
```bash
|
||||
bun install -g @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
✅ **Check:** `uwf --version` prints a version number (e.g. `0.5.1`).
|
||||
|
||||
### 2. Install Agent Adapter
|
||||
|
||||
Install the adapter that matches your agent runtime. Pick **one**:
|
||||
|
||||
| Agent | Package | Binary |
|
||||
|-------|---------|--------|
|
||||
| Hermes | `@uncaged/workflow-agent-hermes` | `uwf-hermes` |
|
||||
|
||||
```bash
|
||||
# Example: Hermes agent
|
||||
bun install -g @uncaged/workflow-agent-hermes
|
||||
```
|
||||
|
||||
✅ **Check:** `uwf-hermes --version` prints a version number.
|
||||
|
||||
### 3. Setup
|
||||
|
||||
Run the interactive wizard:
|
||||
|
||||
```bash
|
||||
uwf setup
|
||||
```
|
||||
|
||||
Or configure non-interactively:
|
||||
|
||||
```bash
|
||||
uwf setup \
|
||||
--provider <name> \
|
||||
--base-url <url> \
|
||||
--api-key <key> \
|
||||
--model <model-name> \
|
||||
--agent hermes
|
||||
```
|
||||
|
||||
This creates `~/.uncaged/workflow/config.yaml` with your provider, model, and default agent.
|
||||
|
||||
#### Config Structure
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
my-provider:
|
||||
baseUrl: https://api.example.com/v1
|
||||
apiKey: sk-xxx
|
||||
models:
|
||||
default:
|
||||
provider: my-provider
|
||||
name: my-model
|
||||
agents:
|
||||
hermes:
|
||||
command: uwf-hermes
|
||||
args: []
|
||||
defaultAgent: hermes
|
||||
defaultModel: default
|
||||
```
|
||||
|
||||
✅ **Check:** `cat ~/.uncaged/workflow/config.yaml` shows valid provider, model, and agent config.
|
||||
|
||||
### 4. Verify Installation
|
||||
|
||||
```bash
|
||||
uwf workflow list # should return empty array or existing workflows
|
||||
uwf skill user # prints usage guide
|
||||
uwf skill author # prints workflow authoring guide
|
||||
```
|
||||
|
||||
✅ **Check:** All three commands run without errors.
|
||||
|
||||
### 5. Add the uwf Skill
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/skills/devops/uwf
|
||||
uwf skill bootstrap > ~/.hermes/skills/devops/uwf/SKILL.md
|
||||
```
|
||||
|
||||
✅ **Check:** `cat ~/.hermes/skills/devops/uwf/SKILL.md` shows the skill content with triggers `uwf`, `workflow`, `工作流`.
|
||||
|
||||
### 6. Smoke Test
|
||||
|
||||
```bash
|
||||
# Register an example workflow
|
||||
uwf workflow add examples/analyze-topic.yaml
|
||||
|
||||
# Start a thread
|
||||
uwf thread start analyze-topic -p "Analyze the concept of technical debt"
|
||||
|
||||
# Execute it (one moderator → agent → extract cycle)
|
||||
uwf thread exec <thread-id>
|
||||
```
|
||||
|
||||
✅ **Check:** Thread reaches `completed` status. Verify with `uwf thread list`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Check & Upgrade
|
||||
|
||||
Already have uwf installed? Run through this checklist to verify and upgrade.
|
||||
|
||||
### Version Check
|
||||
|
||||
```bash
|
||||
uwf --version
|
||||
uwf-hermes --version # or your agent adapter
|
||||
```
|
||||
|
||||
Compare with latest published versions:
|
||||
|
||||
```bash
|
||||
bun pm ls -g | grep -E "cli-workflow|workflow-agent"
|
||||
npm info @uncaged/cli-workflow version
|
||||
npm info @uncaged/workflow-agent-hermes version
|
||||
```
|
||||
|
||||
If local version < published version, upgrade:
|
||||
|
||||
```bash
|
||||
bun install -g @uncaged/cli-workflow@latest
|
||||
bun install -g @uncaged/workflow-agent-hermes@latest
|
||||
```
|
||||
|
||||
✅ **Check:** `uwf --version` matches `npm info @uncaged/cli-workflow version`.
|
||||
|
||||
### Config Check
|
||||
|
||||
```bash
|
||||
cat ~/.uncaged/workflow/config.yaml
|
||||
```
|
||||
|
||||
Verify:
|
||||
- [ ] `providers` has at least one entry with valid `baseUrl` and `apiKey`
|
||||
- [ ] `models.default` references an existing provider
|
||||
- [ ] `agents` has your adapter configured
|
||||
- [ ] `defaultAgent` and `defaultModel` are set
|
||||
|
||||
### Skill Check
|
||||
|
||||
```bash
|
||||
cat ~/.hermes/skills/devops/uwf/SKILL.md
|
||||
```
|
||||
|
||||
Verify the skill is up to date:
|
||||
|
||||
```bash
|
||||
uwf skill bootstrap | diff - ~/.hermes/skills/devops/uwf/SKILL.md
|
||||
```
|
||||
|
||||
If `diff` produces any output, the local skill is outdated. Update:
|
||||
|
||||
```bash
|
||||
uwf skill bootstrap > ~/.hermes/skills/devops/uwf/SKILL.md
|
||||
```
|
||||
|
||||
### Functional Check
|
||||
|
||||
```bash
|
||||
uwf workflow list # should not error
|
||||
uwf skill user # should print usage guide
|
||||
uwf skill author # should print authoring guide
|
||||
```
|
||||
|
||||
✅ All green? You're good to go.
|
||||
@@ -13,7 +13,7 @@ This monorepo implements a stateless workflow engine driven by a single-step CLI
|
||||
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
|
||||
| **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
||||
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
|
||||
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
|
||||
| **CAS** | Content-Addressed Storage via `@ocas/core` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
|
||||
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
|
||||
|
||||
### Monorepo Structure
|
||||
@@ -23,10 +23,9 @@ workflow/
|
||||
packages/
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
||||
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
||||
workflow-moderator/ # @uncaged/workflow-moderator — Status-based graph evaluator
|
||||
workflow-agent-kit/ # @uncaged/workflow-agent-kit — createAgent factory, context builder, extract pipeline
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — createAgent factory, context builder, extract pipeline
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
|
||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary (includes status-based moderator in src/moderator/)
|
||||
legacy-packages/ # Archived packages (preserved for reference, not active)
|
||||
examples/ # Workflow YAML examples (solve-issue.yaml)
|
||||
docs/ # Architecture docs
|
||||
@@ -34,9 +33,9 @@ workflow/
|
||||
tsconfig.json # root TypeScript config
|
||||
```
|
||||
|
||||
- Dependency layers: `workflow-protocol` → (`workflow-util`, `workflow-moderator`) → `workflow-agent-kit` → `workflow-agent-hermes` / `cli-workflow`
|
||||
- Dependency layers: `workflow-protocol` → `workflow-util` → `workflow-util-agent` → `workflow-agent-hermes` / `cli-workflow`
|
||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
|
||||
- External CAS: `@ocas/core` (store API, hashing, schema validation) + `@ocas/fs` (filesystem backend)
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
@@ -271,7 +270,7 @@ node scripts/publish-all.mjs --dry-run # preview without publishing
|
||||
examples/solve-issue.yaml — write a workflow YAML definition
|
||||
│ uwf workflow put
|
||||
▼
|
||||
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
|
||||
~/.uncaged/json-cas/ — Workflow stored as CAS node (unified CAS store)
|
||||
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
|
||||
│ uwf thread start <name> -p "..."
|
||||
▼
|
||||
@@ -285,6 +284,11 @@ moderator → agent → extract — one step per invocation, repeat until $
|
||||
2. **Register** — `uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
|
||||
3. **Run** — `uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
|
||||
|
||||
## Project Rules
|
||||
|
||||
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
|
||||
- [docs/no-dynamic-import.md](docs/no-dynamic-import.md) — no dynamic import in production code
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# Contributing to @uncaged/workflow
|
||||
|
||||
Thank you for your interest in contributing! This guide covers setup, conventions, and the PR workflow.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) (latest)
|
||||
- [Node.js](https://nodejs.org/) 20+
|
||||
- Git
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shazhou-ww/uncaged-workflow.git
|
||||
cd uncaged-workflow
|
||||
bun install
|
||||
bun run build
|
||||
bun test
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
bun run build # TypeScript compilation (all packages)
|
||||
bun run check # tsc + biome lint + log tag validation
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run all tests
|
||||
```
|
||||
|
||||
All three (`build`, `check`, `test`) must pass before submitting a PR. A pre-push hook runs `check` + `test` automatically.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
See [CLAUDE.md](CLAUDE.md) for the full coding standard. Key points:
|
||||
|
||||
- **Functional-first** — `function` + `type`, not `class` + `interface`
|
||||
- **No optional properties** — use `T | null` instead of `?:`
|
||||
- **Named exports only** — no default exports
|
||||
- **No `console.log`** — use the structured logger from `@uncaged/workflow-util`
|
||||
- **Static imports only** — no `await import()` in production code
|
||||
- **Biome** for lint + format — run `bun run check` before committing
|
||||
|
||||
## Commit Messages
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: cli | moderator | agent-kit | hermes | builtin | claude-code | util | protocol | dashboard
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `feat(moderator): add cycle detection to graph evaluator`
|
||||
- `fix(cli): handle missing config file gracefully`
|
||||
- `docs(protocol): update StepNode field descriptions`
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Branch** from `main`: `git checkout -b feat/123-short-description`
|
||||
2. **Implement** your change with tests
|
||||
3. **Run checks**: `bun run check && bun test`
|
||||
4. **Commit** with a descriptive message referencing the issue: `Fixes #123`
|
||||
5. **Push** and open a PR
|
||||
|
||||
### PR Description Template
|
||||
|
||||
```
|
||||
## What
|
||||
What this PR does.
|
||||
|
||||
## Why
|
||||
Why the change is needed.
|
||||
|
||||
## Changes
|
||||
- `path/to/file.ts` — what changed and why
|
||||
|
||||
## Ref
|
||||
Fixes #N
|
||||
```
|
||||
|
||||
## Adding a Changeset
|
||||
|
||||
For any user-facing change (feat, fix, breaking change), add a changeset:
|
||||
|
||||
```bash
|
||||
bun changeset
|
||||
```
|
||||
|
||||
This creates a markdown file in `.changeset/` describing the change. It will be consumed on the next release to bump versions and generate CHANGELOG entries.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/
|
||||
workflow-protocol/ # Shared types and JSON Schema
|
||||
workflow-util/ # Encoding, IDs, logging, frontmatter
|
||||
workflow-util-agent/ # createAgent factory, extract pipeline
|
||||
workflow-agent-hermes/ # Hermes ACP agent
|
||||
workflow-agent-builtin/ # Built-in LLM agent
|
||||
workflow-agent-claude-code/ # Claude Code agent
|
||||
cli-workflow/ # uwf CLI binary
|
||||
workflow-dashboard/ # Web UI (private, alpha)
|
||||
```
|
||||
|
||||
Dependency flows downward — lower layers have no dependency on higher layers. See [CLAUDE.md](CLAUDE.md) for the full architecture.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Uncaged
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,5 +1,10 @@
|
||||
# @uncaged/workflow
|
||||
|
||||
[](https://github.com/shazhou-ww/uncaged-workflow/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/@uncaged/cli-workflow)
|
||||
[](https://www.npmjs.com/package/@uncaged/workflow-protocol)
|
||||
[](https://www.npmjs.com/package/@uncaged/workflow-util-agent)
|
||||
|
||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, status-based routing, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
||||
|
||||
## Overview
|
||||
@@ -10,50 +15,13 @@ Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes fo
|
||||
|
||||
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
|
||||
|
||||
## Architecture
|
||||
## Install
|
||||
|
||||
Dependency layers (lower layers have no dependency on higher layers):
|
||||
|
||||
```
|
||||
Layer 0 — Contract
|
||||
workflow-protocol Shared types and JSON Schema definitions
|
||||
|
||||
Layer 1 — Shared infra
|
||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
||||
workflow-moderator Status-based graph evaluator
|
||||
|
||||
Layer 2 — Agent framework
|
||||
workflow-agent-kit createAgent factory, context builder, extract pipeline
|
||||
|
||||
Layer 3 — Agent implementations
|
||||
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
|
||||
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
|
||||
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
|
||||
|
||||
Layer 4 — CLI
|
||||
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup
|
||||
|
||||
App (uses protocol; not in the runtime engine stack)
|
||||
workflow-dashboard Web UI for visual workflow editing
|
||||
```bash
|
||||
npm install -g @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | npm | Description | Type | README |
|
||||
|---------|-----|-------------|------|--------|
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
|
||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
|
||||
| `workflow-moderator` | `@uncaged/workflow-moderator` | Status-based graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/README.md) |
|
||||
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-agent-kit/README.md) |
|
||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
||||
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
|
||||
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
|
||||
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
|
||||
Requires [Bun](https://bun.sh/) runtime (used internally for TypeScript execution).
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -73,6 +41,49 @@ uwf thread exec <thread-id>
|
||||
|
||||
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Dependency layers (lower layers have no dependency on higher layers):
|
||||
|
||||
```
|
||||
Layer 0 — Contract
|
||||
workflow-protocol Shared types and JSON Schema definitions
|
||||
|
||||
Layer 1 — Shared infra
|
||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
||||
|
||||
Layer 2 — Agent framework
|
||||
workflow-util-agent createAgent factory, context builder, extract pipeline
|
||||
|
||||
Layer 3 — Agent implementations
|
||||
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
|
||||
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
|
||||
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
|
||||
|
||||
Layer 4 — CLI
|
||||
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup (includes status-based moderator)
|
||||
|
||||
App (uses protocol; not in the runtime engine stack)
|
||||
workflow-dashboard Web UI for visual workflow editing
|
||||
```
|
||||
|
||||
External CAS: [`@ocas/core`](https://www.npmjs.com/package/@ocas/core) (store API, hashing, schema validation) + `@ocas/fs` (filesystem backend).
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | npm | Description | Type | README |
|
||||
|---------|-----|-------------|------|--------|
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
|
||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
|
||||
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-util-agent/README.md) |
|
||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
||||
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
|
||||
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
|
||||
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
||||
|
||||
+4
-2
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!.worktrees",
|
||||
"!**/node_modules",
|
||||
"!**/legacy-packages",
|
||||
"!scripts",
|
||||
@@ -38,7 +39,8 @@
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
"noExplicitAny": "off",
|
||||
"noConsole": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
|
||||
+24
-28
@@ -8,26 +8,25 @@
|
||||
|
||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
||||
|
||||
The implementation lives in **6** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
|
||||
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@ocas/core`, `@ocas/fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
|
||||
|
||||
## Package map
|
||||
|
||||
| Layer | Package | One-line role |
|
||||
|-------|---------|---------------|
|
||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
|
||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@ocas/fs`. |
|
||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
|
||||
| Moderator | `@uncaged/workflow-moderator` → `workflow-moderator` | Status-based graph evaluator: given a routing graph, last role, and last output, returns the next role or `$END`. |
|
||||
| Agent framework | `@uncaged/workflow-agent-kit` → `workflow-agent-kit` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
||||
| Agent framework | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
||||
| Agent: Hermes | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. Includes status-based graph evaluator in `src/moderator/` (next role or `$END`). |
|
||||
|
||||
### External dependencies
|
||||
|
||||
| Package | Role |
|
||||
|---------|------|
|
||||
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
||||
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
|
||||
| `mustache` | Template renderer for edge prompts (used by `workflow-moderator`). |
|
||||
| `@ocas/core` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
||||
| `@ocas/fs` | Filesystem backend for `ocas`. |
|
||||
| `mustache` | Template renderer for edge prompts (used by `cli-workflow` moderator). |
|
||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
||||
| `dotenv` | Loads `.env` files for API keys. |
|
||||
| `yaml` | YAML parse/stringify. |
|
||||
@@ -37,18 +36,17 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|
||||
```mermaid
|
||||
flowchart BT
|
||||
subgraph External
|
||||
jcas["@uncaged/json-cas"]
|
||||
jcasfs["@uncaged/json-cas-fs"]
|
||||
jcas["@ocas/core"]
|
||||
jcasfs["@ocas/fs"]
|
||||
end
|
||||
subgraph L0["Layer 0 — contract"]
|
||||
protocol["@uncaged/workflow-protocol"]
|
||||
end
|
||||
subgraph L1["Layer 1 — shared"]
|
||||
util["@uncaged/workflow-util"]
|
||||
moderator["@uncaged/workflow-moderator"]
|
||||
end
|
||||
subgraph L2["Layer 2 — agent framework"]
|
||||
kit["@uncaged/workflow-agent-kit"]
|
||||
kit["@uncaged/workflow-util-agent"]
|
||||
end
|
||||
subgraph L3["Layer 3 — agent implementations"]
|
||||
hermes["@uncaged/workflow-agent-hermes"]
|
||||
@@ -58,7 +56,6 @@ flowchart BT
|
||||
end
|
||||
protocol --> jcasfs
|
||||
util --> protocol
|
||||
moderator --> protocol
|
||||
kit --> protocol
|
||||
kit --> util
|
||||
kit --> jcas
|
||||
@@ -68,7 +65,6 @@ flowchart BT
|
||||
cli --> protocol
|
||||
cli --> util
|
||||
cli --> kit
|
||||
cli --> moderator
|
||||
cli --> jcas
|
||||
cli --> jcasfs
|
||||
```
|
||||
@@ -150,7 +146,7 @@ Key properties:
|
||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
||||
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
|
||||
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
||||
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
|
||||
- **No Zod** — all schemas are JSON Schema, validated through `@ocas/core`
|
||||
|
||||
## Three-phase engine loop
|
||||
|
||||
@@ -222,7 +218,7 @@ Each agent is an external command invoked by `uwf thread step`:
|
||||
Contract:
|
||||
1. `uwf thread step` determines the next role via the moderator
|
||||
2. Agent CLI is spawned with `(thread-id, role)` as positional args
|
||||
3. `workflow-agent-kit` (`createAgent`) handles the boilerplate:
|
||||
3. `workflow-util-agent` (`createAgent`) handles the boilerplate:
|
||||
- Parses argv
|
||||
- Loads `.env` from storage root
|
||||
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
||||
@@ -255,11 +251,11 @@ scope: role
|
||||
Fixed the login redirect by updating the auth middleware...
|
||||
```
|
||||
|
||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
|
||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-util-agent`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
|
||||
|
||||
## Two-layer extract
|
||||
|
||||
Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
|
||||
Structured output extraction uses a two-layer strategy (`workflow-util-agent`):
|
||||
|
||||
### Layer 1: frontmatter fast path (`frontmatter.ts`)
|
||||
|
||||
@@ -267,7 +263,7 @@ Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
|
||||
2. Validate required fields (`validateFrontmatter`)
|
||||
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
|
||||
4. `store.put()` the candidate against the role's `meta` schema
|
||||
5. Validate with `json-cas` schema validation
|
||||
5. Validate with `ocas` schema validation
|
||||
6. If valid → return `outputHash` (zero LLM cost)
|
||||
|
||||
### Layer 2: LLM extract fallback (`extract.ts`)
|
||||
@@ -283,7 +279,7 @@ If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy sch
|
||||
|
||||
## Prompt injection
|
||||
|
||||
`workflow-agent-kit` prepends two pieces of context to the agent's system prompt:
|
||||
`workflow-util-agent` prepends two pieces of context to the agent's system prompt:
|
||||
|
||||
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
|
||||
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
|
||||
@@ -306,7 +302,7 @@ payload:
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
|
||||
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema node
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected"
|
||||
@@ -322,7 +318,7 @@ payload:
|
||||
```yaml
|
||||
type: <start-node-schema-hash>
|
||||
payload:
|
||||
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
||||
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
|
||||
prompt: "Fix the login bug..."
|
||||
```
|
||||
|
||||
@@ -331,11 +327,11 @@ payload:
|
||||
```yaml
|
||||
type: <step-node-schema-hash>
|
||||
payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
|
||||
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode
|
||||
prev: "2MXBG6PN4A8JR" # ocas_ref → previous StepNode (null for first step)
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against meta schema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
|
||||
output: "9KRVW3TN5F1QA" # ocas_ref → structured output (validated against meta schema)
|
||||
detail: "7BQST3VW9F2MA" # ocas_ref → execution detail (raw turns, session data)
|
||||
agent: "uwf-hermes" # agent command used (plain string)
|
||||
```
|
||||
|
||||
@@ -395,7 +391,7 @@ Everything else is immutable CAS content.
|
||||
providers:
|
||||
openrouter:
|
||||
baseUrl: "https://openrouter.ai/api/v1"
|
||||
apiKeyEnv: "OPENROUTER_API_KEY"
|
||||
apiKey: "sk-..."
|
||||
|
||||
models:
|
||||
sonnet:
|
||||
@@ -488,7 +484,7 @@ Binary: `uwf`
|
||||
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
|
||||
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
|
||||
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
|
||||
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `json-cas`. No code generation, no runtime library dependency. |
|
||||
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `ocas`. No code generation, no runtime library dependency. |
|
||||
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
|
||||
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
|
||||
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
|
||||
|
||||
@@ -78,9 +78,9 @@ Agent 解析优先级(`resolveAgentConfig`):
|
||||
|
||||
#### 环境变量:Storage Root
|
||||
|
||||
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-agent-kit` / `cli-workflow` 一致):
|
||||
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-util-agent` / `cli-workflow` 一致):
|
||||
|
||||
```33:43:packages/workflow-agent-kit/src/storage.ts
|
||||
```33:43:packages/workflow-util-agent/src/storage.ts
|
||||
export function resolveStorageRoot(): string {
|
||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (internal !== undefined && internal !== "") {
|
||||
@@ -107,7 +107,7 @@ Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage roo
|
||||
|
||||
### Q2: createAgent 工厂
|
||||
|
||||
workflow-agent-kit 的 `createAgent` 做了什么?它的完整生命周期是什么?
|
||||
workflow-util-agent 的 `createAgent` 做了什么?它的完整生命周期是什么?
|
||||
|
||||
**调研要点:**
|
||||
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
|
||||
@@ -119,7 +119,7 @@ workflow-agent-kit 的 `createAgent` 做了什么?它的完整生命周期是
|
||||
|
||||
#### 类型定义
|
||||
|
||||
```4:35:packages/workflow-agent-kit/src/types.ts
|
||||
```4:35:packages/workflow-util-agent/src/types.ts
|
||||
export type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
@@ -156,7 +156,7 @@ export type AgentOptions = {
|
||||
|
||||
#### 生命周期(按执行顺序)
|
||||
|
||||
```101:152:packages/workflow-agent-kit/src/run.ts
|
||||
```101:152:packages/workflow-util-agent/src/run.ts
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
@@ -197,7 +197,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
|
||||
#### StepNode 写入结构
|
||||
|
||||
```44:68:packages/workflow-agent-kit/src/run.ts
|
||||
```44:68:packages/workflow-util-agent/src/run.ts
|
||||
async function writeStepNode(options: {
|
||||
store: AgentStore["store"];
|
||||
schemas: AgentStore["schemas"];
|
||||
@@ -274,7 +274,7 @@ export type StepContext = Omit<StepRecord, "output"> & {
|
||||
|
||||
`buildContextWithMeta` 还返回 `meta`:
|
||||
|
||||
```148:154:packages/workflow-agent-kit/src/context.ts
|
||||
```148:154:packages/workflow-util-agent/src/context.ts
|
||||
export type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
@@ -337,7 +337,7 @@ async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef>
|
||||
|
||||
#### Frontmatter fast-path(createAgent 实际使用的路径)
|
||||
|
||||
```148:195:packages/workflow-agent-kit/src/frontmatter.ts
|
||||
```148:195:packages/workflow-util-agent/src/frontmatter.ts
|
||||
export async function tryFrontmatterFastPath(
|
||||
raw: string,
|
||||
outputSchema: CasRef,
|
||||
@@ -357,7 +357,7 @@ export async function tryFrontmatterFastPath(
|
||||
|
||||
#### LLM extract fallback(已实现但未接入 createAgent)
|
||||
|
||||
```135:181:packages/workflow-agent-kit/src/extract.ts
|
||||
```135:181:packages/workflow-util-agent/src/extract.ts
|
||||
export async function extract(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
@@ -374,7 +374,7 @@ export async function extract(
|
||||
|
||||
#### Correction prompt(retry)
|
||||
|
||||
```125:128:packages/workflow-agent-kit/src/run.ts
|
||||
```125:128:packages/workflow-util-agent/src/run.ts
|
||||
const correctionMessage =
|
||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||
@@ -402,7 +402,7 @@ workflow 怎么配置和使用 model?
|
||||
```136:160:packages/workflow-protocol/src/types.ts
|
||||
export type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKeyEnv: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ModelConfig = {
|
||||
@@ -425,11 +425,11 @@ export type WorkflowConfig = {
|
||||
|
||||
#### resolveModel
|
||||
|
||||
```32:50:packages/workflow-agent-kit/src/extract.ts
|
||||
```32:50:packages/workflow-util-agent/src/extract.ts
|
||||
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
||||
const modelEntry = config.models[alias];
|
||||
const providerEntry = config.providers[modelEntry.provider];
|
||||
const apiKey = process.env[providerEntry.apiKeyEnv];
|
||||
const apiKey = providerEntry.apiKey;
|
||||
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
|
||||
}
|
||||
```
|
||||
@@ -438,7 +438,7 @@ export function resolveModel(config: WorkflowConfig, alias: ModelAlias): Resolve
|
||||
|
||||
Extract 专用别名解析:
|
||||
|
||||
```18:30:packages/workflow-agent-kit/src/extract.ts
|
||||
```18:30:packages/workflow-util-agent/src/extract.ts
|
||||
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
|
||||
}
|
||||
@@ -448,7 +448,7 @@ export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||
|
||||
#### chatCompletionText
|
||||
|
||||
```87:124:packages/workflow-agent-kit/src/extract.ts
|
||||
```87:124:packages/workflow-util-agent/src/extract.ts
|
||||
async function chatCompletionText(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: Array<{ role: "system" | "user"; content: string }>,
|
||||
@@ -463,7 +463,7 @@ async function chatCompletionText(
|
||||
| 多模态 | **无**(仅 text `content`) |
|
||||
| Extract 专用 | `response_format: { type: "json_object" }` |
|
||||
|
||||
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-agent-kit` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
|
||||
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-util-agent` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
|
||||
|
||||
---
|
||||
|
||||
@@ -609,7 +609,7 @@ flowchart TB
|
||||
Loop --> Detail
|
||||
end
|
||||
|
||||
subgraph kit ["workflow-agent-kit"]
|
||||
subgraph kit ["workflow-util-agent"]
|
||||
Ctx["buildContextWithMeta"]
|
||||
FM["tryFrontmatterFastPath"]
|
||||
Persist["persistStep"]
|
||||
@@ -630,7 +630,7 @@ flowchart TB
|
||||
Spawn -->|"stdout: step hash"| Step
|
||||
```
|
||||
|
||||
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-agent-kit`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
|
||||
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@ocas/core` 写 detail schema)。
|
||||
|
||||
**分层**:
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Ban dynamic import() in production code — use static imports instead
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# No Dynamic Import in Production Code
|
||||
|
||||
## Rule
|
||||
|
||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||
Always use static top-level `import` statements.
|
||||
|
||||
## Exception (must include a comment explaining why)
|
||||
|
||||
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Sync README
|
||||
|
||||
When updating README.md files in this monorepo, follow these conventions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Root `README.md` — project overview and navigation hub
|
||||
- Per-package `packages/*/README.md` — each package self-contained
|
||||
|
||||
## Root README Structure
|
||||
|
||||
The root README should have these sections in order:
|
||||
|
||||
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
|
||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||
3. **Architecture** — dependency layer diagram (text-based)
|
||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
|
||||
5. **Quick Start** — install, build, register workflow, start thread, run step
|
||||
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
|
||||
7. **Development** — bun install / build / check / test
|
||||
|
||||
## Per-Package README Structure
|
||||
|
||||
Each package README should have:
|
||||
|
||||
1. **Title** — package name
|
||||
2. **One-line description** — matching package.json
|
||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
|
||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||
6. **CLI Usage** (cli/agent packages) — command reference with examples
|
||||
7. **Internal Structure** — brief src/ file organization
|
||||
8. **Configuration** (if applicable)
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Gather current state
|
||||
For each package read:
|
||||
- package.json (name, version, description, dependencies, bin)
|
||||
- src/index.ts (public API exports)
|
||||
- Existing README.md (preserve hand-written content worth keeping)
|
||||
|
||||
### Step 2: Update root README
|
||||
- Ensure ALL packages in packages/ directory are listed in the table
|
||||
- Update CLI command reference from uwf --help output
|
||||
- Keep Quick Start examples valid
|
||||
|
||||
### Step 3: Write/update each package README
|
||||
- Follow the per-package structure
|
||||
- API section MUST match actual src/index.ts exports — never invent
|
||||
- For agent packages: document CLI binary name, how it is invoked
|
||||
- For lib packages: document exported types and functions
|
||||
- Internal structure: list actual files in src/
|
||||
|
||||
### Step 4: Verify
|
||||
- All relative links work
|
||||
- Package names match package.json
|
||||
- No references to removed/renamed packages
|
||||
- bun run build still passes
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only document what src/index.ts actually exports
|
||||
- Root README summarizes, package READMEs go into detail
|
||||
- Verify CLI examples against actual commands
|
||||
- Preserve existing good prose when updating
|
||||
- English for all README content
|
||||
+31
-32
@@ -22,7 +22,7 @@ uwf workflow show <workflow-id> # 查看 workflow 定义
|
||||
uwf workflow list # 列出已注册 workflows
|
||||
```
|
||||
|
||||
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
|
||||
两组对称,各 3-4 个子命令。CAS 操作交给 `ocas` CLI,不在 `uwf` 中重复。
|
||||
|
||||
### 1.2 `uwf thread start`
|
||||
|
||||
@@ -136,14 +136,14 @@ uwf-hermes <thread-id> <role>
|
||||
|
||||
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
|
||||
|
||||
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
|
||||
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
|
||||
下面所有 CAS 节点都遵循 `{ type: ocas_ref, payload: T, timestamp: number }` 的标准格式。
|
||||
`ocas_ref` 类型的字符串字段在 ocas 中已内置支持,不需要额外的 `$ref` 包装。
|
||||
|
||||
### 2.2 数据节点
|
||||
|
||||
#### `Workflow`
|
||||
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 ocas 校验)。
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
@@ -157,21 +157,21 @@ payload:
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema 节点(ocas 内置)
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent..."
|
||||
capabilities: [file-edit, shell]
|
||||
procedure: "Implement the plan."
|
||||
output: "List all files changed."
|
||||
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
meta: "8CNWT4KR6D1HV" # ocas_ref → JSON Schema 节点
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer..."
|
||||
capabilities: [code-review]
|
||||
procedure: "Review the implementation."
|
||||
output: "Approve or reject with comments."
|
||||
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
meta: "1VPBG9SM5E7WK" # ocas_ref → JSON Schema 节点
|
||||
conditions:
|
||||
needsClarification:
|
||||
description: "Planner requests clarification from user"
|
||||
@@ -198,7 +198,7 @@ payload:
|
||||
condition: null
|
||||
```
|
||||
|
||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 ocas_ref(指向 ocas 内置 JSON Schema 节点)
|
||||
- `graph` — `Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
|
||||
- Status 来自上一个 role 输出的 `status` 字段,`$START` 用 `_` 作为初始 status
|
||||
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
|
||||
@@ -220,7 +220,7 @@ evaluate(graph, lastRole, lastOutput) → { role, prompt }
|
||||
```yaml
|
||||
type: <start-node-schema-hash>
|
||||
payload:
|
||||
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
||||
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
|
||||
prompt: "Fix the login bug..."
|
||||
```
|
||||
|
||||
@@ -232,18 +232,18 @@ payload:
|
||||
```yaml
|
||||
type: <step-node-schema-hash>
|
||||
payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
||||
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode(每个 step 都引用)
|
||||
prev: "2MXBG6PN4A8JR" # ocas_ref → 前一个 StepNode,第一步为 null
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||
output: "9KRVW3TN5F1QA" # ocas_ref → 结构化输出节点(符合 role 的 meta schema)
|
||||
detail: "7BQST3VW9F2MA" # ocas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
||||
```
|
||||
|
||||
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
||||
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
||||
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
|
||||
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||
- `prev` — 前一个 StepNode 的 ocas_ref,第一步为 `null`(不指向 StartNode)
|
||||
- `output` — ocas_ref,指向符合 role meta schema 的 CAS 节点,可用 ocas 校验
|
||||
- `detail` — ocas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||
- `agent` — 纯字符串,不是 CAS 节点
|
||||
|
||||
### 2.3 链式结构
|
||||
@@ -280,13 +280,13 @@ threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
|
||||
providers:
|
||||
openai:
|
||||
baseUrl: "https://api.openai.com/v1"
|
||||
apiKeyEnv: "OPENAI_API_KEY"
|
||||
apiKey: "sk-..."
|
||||
anthropic:
|
||||
baseUrl: "https://api.anthropic.com/v1"
|
||||
apiKeyEnv: "ANTHROPIC_API_KEY"
|
||||
apiKey: "sk-ant-..."
|
||||
openrouter:
|
||||
baseUrl: "https://openrouter.ai/api/v1"
|
||||
apiKeyEnv: "OPENROUTER_API_KEY"
|
||||
apiKey: "sk-or-..."
|
||||
|
||||
models:
|
||||
sonnet:
|
||||
@@ -337,21 +337,20 @@ OPENROUTER_API_KEY=sk-or-...
|
||||
|
||||
## 3. 包结构
|
||||
|
||||
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`。
|
||||
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@ocas/core`。
|
||||
|
||||
```
|
||||
packages/
|
||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
|
||||
├── workflow-moderator/ # @uncaged/workflow-moderator — Status-based moderator 引擎
|
||||
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
|
||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令,含 src/moderator/)
|
||||
├── workflow-util-agent/ # @uncaged/workflow-util-agent — Agent CLI 框架(含 extractor)
|
||||
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
||||
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
||||
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
|
||||
```
|
||||
|
||||
**外部依赖:**
|
||||
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
|
||||
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
|
||||
- `@ocas/core` — CAS 存储、hash、schema 校验
|
||||
- `@ocas/fs` — 文件系统 CAS 后端
|
||||
|
||||
**现有包全部保留不动**,新旧并存,逐步迁移。
|
||||
|
||||
@@ -373,8 +372,8 @@ type ThreadId = string;
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
|
||||
type StepRecord = {
|
||||
role: string;
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
||||
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||
output: CasRef; // ocas_ref → 结构化输出节点(符合 role meta schema)
|
||||
detail: CasRef; // ocas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||
agent: string; // 实际使用的 agent 命令(纯字符串)
|
||||
};
|
||||
```
|
||||
@@ -388,7 +387,7 @@ type RoleDefinition = {
|
||||
capabilities: string[];
|
||||
procedure: string;
|
||||
output: string;
|
||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
meta: CasRef; // ocas_ref → ocas 内置 JSON Schema 节点
|
||||
};
|
||||
|
||||
type Target = {
|
||||
@@ -408,13 +407,13 @@ type WorkflowPayload = {
|
||||
|
||||
```typescript
|
||||
type StartNodePayload = {
|
||||
workflow: CasRef; // cas_ref → Workflow
|
||||
workflow: CasRef; // ocas_ref → Workflow
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
type StepNodePayload = StepRecord & {
|
||||
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
|
||||
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
|
||||
start: CasRef; // ocas_ref → StartNode(每个 step 都引用)
|
||||
prev: CasRef | null; // ocas_ref → 前一个 StepNode,第一步为 null
|
||||
};
|
||||
```
|
||||
|
||||
@@ -466,7 +465,7 @@ type Scenario = string; // e.g. "extract"
|
||||
|
||||
type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKeyEnv: string; // env var name to read API key from
|
||||
apiKey: string; // API key stored directly
|
||||
};
|
||||
|
||||
type ModelConfig = {
|
||||
|
||||
@@ -22,7 +22,7 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
thesis:
|
||||
type: string
|
||||
@@ -32,7 +32,7 @@ roles:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [status, thesis, keyPoints]
|
||||
required: [$status, thesis, keyPoints]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||
|
||||
@@ -21,11 +21,11 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [status, argument]
|
||||
required: [$status, argument]
|
||||
for:
|
||||
description: "Argues for the proposition"
|
||||
goal: |
|
||||
@@ -46,11 +46,11 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [status, argument]
|
||||
required: [$status, argument]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||
|
||||
+212
-69
@@ -1,92 +1,235 @@
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent. You analyze issues and create implementation plans grounded in the actual codebase."
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
- file-read
|
||||
- shell
|
||||
procedure: |
|
||||
1. Locate the code repository:
|
||||
- Check if the current working directory is the repo (look for package.json, .git, etc.)
|
||||
- If the task mentions a repo URL, clone it first.
|
||||
- If this is a new project, create the repo and note the path.
|
||||
2. Explore the codebase — read the relevant source files mentioned in the issue. Understand the current architecture, types, and conventions (check CLAUDE.md, CONTRIBUTING.md, .cursor/rules/).
|
||||
3. Identify which files need changes and what the changes should be, with specific code references.
|
||||
4. Output the plan with:
|
||||
- `repoPath`: absolute path to the repository root
|
||||
- `plan`: detailed implementation plan with file paths and code references
|
||||
- `steps`: concrete action items for the developer
|
||||
output: |
|
||||
Provide repoPath, plan summary, and steps in the frontmatter.
|
||||
The plan MUST reference actual file paths and code structures you found by reading the source.
|
||||
Do NOT guess — if you haven't read a file, read it before referencing it.
|
||||
CRITICAL: First, determine which mode you are in by scanning the task prompt.
|
||||
Choose EXACTLY ONE mode — do NOT default to Mode A if Mode B applies.
|
||||
|
||||
**How to choose:**
|
||||
- If the prompt contains ANY of these keywords: "PR #", "PR#", "pulls/", "继续修复", "continue", "review feedback", "existing branch", "fix/", or mentions a branch name → **Mode B**
|
||||
- If the prompt was forwarded from tester with fix_spec → **Mode C**
|
||||
- Otherwise → **Mode A**
|
||||
|
||||
**Mode A — Fresh issue (first time, no existing PR):**
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
6. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
7. Output **$status=ready** with plan hash and repoPath
|
||||
|
||||
**Mode B — Continue on existing PR (prompt mentions PR, branch, or review feedback):**
|
||||
YOU MUST output $status=continue (NOT ready) when in this mode.
|
||||
1. Extract the PR number and branch name from the prompt
|
||||
2. Read the PR and its review comments from Gitea: `tea pr <number> --comments -r <owner/repo>`
|
||||
3. Read the existing issue for full context: `tea issues <number> -r <owner/repo>`
|
||||
4. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
5. Produce a TDD test spec that ONLY covers the changes requested in the review — do NOT re-spec already-implemented features
|
||||
6. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
7. Find the existing worktree: `git worktree list` and locate the branch
|
||||
8. Output **$status=continue** with plan hash, repoPath, branch name, and worktree path
|
||||
|
||||
**Mode C — Bounced back by tester (fix_spec):**
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
2. Revise the test spec accordingly
|
||||
3. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
4. Output **$status=ready** with plan hash and repoPath
|
||||
|
||||
IMPORTANT: Extract the repo remote (owner/repo) from git:
|
||||
```bash
|
||||
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
|
||||
```
|
||||
Store the result as repoRemote in your frontmatter output so downstream roles can use it.
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (fresh), continue (existing PR), or insufficient_info."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
repoPath:
|
||||
type: string
|
||||
plan:
|
||||
type: string
|
||||
required: [status, repoPath, plan]
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "continue" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, plan, repoPath, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. You implement code changes according to plans."
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
capabilities:
|
||||
- file-edit
|
||||
- shell
|
||||
- testing
|
||||
- coding
|
||||
procedure: |
|
||||
1. Read the planner's output to get the repoPath and implementation plan.
|
||||
2. cd to the repoPath before making any changes.
|
||||
3. Create a feature branch from the default branch.
|
||||
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
||||
5. Run the project's lint/check command (e.g. `bun run check`, `npm run lint`) and fix ALL errors before proceeding. Build and lint must pass cleanly.
|
||||
6. Commit your changes with a descriptive message referencing the issue.
|
||||
output: "List all files changed and provide a summary of the implementation."
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The repo path and other details are provided in your task prompt.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. cd into the repo path provided in your task prompt
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If continuing on existing branch (prompt says "Continue work on existing branch" or provides a worktree path):
|
||||
- cd directly into the worktree path provided in the prompt
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
- Do NOT create a new branch or worktree
|
||||
5. If bounced back from reviewer or tester (branch already exists but no explicit worktree path):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
6. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
filesChanged:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
required: [status, filesChanged, summary]
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
1. Run hard checks first — build (`bun run build` or equivalent) and lint (`bunx biome check .` or equivalent) MUST pass with zero errors. If they fail, reject immediately.
|
||||
2. Then review code quality: correctness, edge cases, naming, project conventions (CLAUDE.md), and test coverage.
|
||||
3. Only reject for hard check failures or genuine correctness/security issues. Style suggestions alone should not block approval.
|
||||
output: "Approve or reject with detailed comments explaining your decision."
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
|
||||
Then perform code review:
|
||||
Hard checks (must all pass):
|
||||
3. `bun run build` — no build errors
|
||||
4. `bunx biome check` — no lint violations
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||
- Naming conventions, module boundaries, code style
|
||||
- No `console.log` in production code
|
||||
- No dynamic imports in production code
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["approved", "rejected"]
|
||||
comments:
|
||||
type: string
|
||||
required: [status, comments]
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
|
||||
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||
5. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
planner:
|
||||
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
|
||||
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||
continue: { role: "developer", prompt: "Continue work on existing branch {{{branch}}} at worktree {{{worktree}}}. Implement the revised TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Do NOT create a new branch or worktree — cd into the existing worktree and work there." }
|
||||
developer:
|
||||
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
|
||||
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
|
||||
+19
-9
@@ -21,7 +21,7 @@ const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||
|
||||
describe("evaluate", () => {
|
||||
test("$START → first role (unit status _)", () => {
|
||||
const result = evaluate(solveIssueGraph, "$START", { status: "_" });
|
||||
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
@@ -30,7 +30,7 @@ describe("evaluate", () => {
|
||||
|
||||
test("status-based routing (reviewer rejected → developer)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
status: "rejected",
|
||||
$status: "rejected",
|
||||
comments: "missing tests",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
@@ -40,7 +40,7 @@ describe("evaluate", () => {
|
||||
});
|
||||
|
||||
test("status-based routing (reviewer approved → $END)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "approved" });
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Done." },
|
||||
@@ -48,7 +48,7 @@ describe("evaluate", () => {
|
||||
});
|
||||
|
||||
test("missing role in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "unknown-role", { status: "_" });
|
||||
const result = evaluate(solveIssueGraph, "unknown-role", { $status: "_" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||
@@ -56,7 +56,7 @@ describe("evaluate", () => {
|
||||
});
|
||||
|
||||
test("missing status in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "pending" });
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "pending" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||
@@ -65,7 +65,7 @@ describe("evaluate", () => {
|
||||
|
||||
test("mustache template rendering with simple fields", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
status: "_",
|
||||
$status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
@@ -76,7 +76,7 @@ describe("evaluate", () => {
|
||||
|
||||
test("mustache does not HTML-escape prompt content", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
status: "rejected",
|
||||
$status: "rejected",
|
||||
comments: 'use <T> & "Result<T, E>" types',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
@@ -92,7 +92,7 @@ describe("evaluate", () => {
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
status: "_",
|
||||
$status: "_",
|
||||
comments: "<script>alert(1)</script>",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
@@ -101,6 +101,16 @@ describe("evaluate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("missing $status defaults to _ (unit routing)", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
test("mustache template with nested object paths", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
@@ -111,7 +121,7 @@ describe("evaluate", () => {
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
status: "_",
|
||||
$status: "_",
|
||||
review: { comments: "refactor the handler" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
+13
-2
@@ -15,7 +15,8 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
"test": "bun test",
|
||||
"test:ci": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
@@ -27,5 +28,15 @@
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"directory": "legacy-packages/workflow-moderator"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
+9
-2
@@ -9,14 +9,21 @@ mustache.escape = (text: string) => text;
|
||||
const START_ROLE = "$START";
|
||||
const UNIT_STATUS = "_";
|
||||
|
||||
type LastOutput = Record<string, unknown> & { status: string };
|
||||
type LastOutput = Record<string, unknown>;
|
||||
|
||||
const STATUS_KEY = "$status";
|
||||
|
||||
export function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: LastOutput,
|
||||
): Result<EvaluateResult, Error> {
|
||||
const status = lastRole === START_ROLE ? UNIT_STATUS : lastOutput.status;
|
||||
const status =
|
||||
lastRole === START_ROLE
|
||||
? UNIT_STATUS
|
||||
: typeof lastOutput[STATUS_KEY] === "string"
|
||||
? (lastOutput[STATUS_KEY] as string)
|
||||
: UNIT_STATUS;
|
||||
|
||||
const roleTargets = graph[lastRole];
|
||||
if (roleTargets === undefined) {
|
||||
+18
-3
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-monorepo",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.14",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"uwf": "bun packages/cli-workflow/src/cli.ts",
|
||||
"preinstall": "npx only-allow bun",
|
||||
"prepublishOnly": "echo 'Use bun run release instead' && exit 1",
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter './packages/*' test",
|
||||
"test:ci": "bun run --filter './packages/*' test:ci",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && node scripts/publish-all.mjs"
|
||||
"release": "bun run build && bun run test && node scripts/publish-all.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
@@ -22,6 +26,17 @@
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"@uncaged/workflow-agent-hermes": "workspace:*",
|
||||
"bun-types": "^1.3.13"
|
||||
}
|
||||
"bun-types": "^1.3.13",
|
||||
"typescript": "^5.8.3",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ workflow → thread → step → turn
|
||||
|
||||
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-moderator`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `yaml`
|
||||
**Dependencies:** `@ocas/core`, `@ocas/fs`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `mustache`, `yaml`
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -119,7 +119,7 @@ uwf setup --provider openai --base-url https://api.openai.com/v1 \
|
||||
--api-key sk-... --model gpt-4o --agent hermes
|
||||
```
|
||||
|
||||
Config: `~/.uncaged/workflow/config.yaml`. API keys: `~/.uncaged/workflow/.env`.
|
||||
Config: `~/.uncaged/workflow/config.yaml` (includes API keys).
|
||||
|
||||
### Skill
|
||||
|
||||
@@ -190,6 +190,7 @@ src/
|
||||
├── store.ts CAS store + registry initialization
|
||||
├── validate.ts Workflow YAML validation
|
||||
├── schemas.ts CLI-local schema registration
|
||||
├── moderator/ Status-based graph evaluator (next role or $END)
|
||||
└── commands/
|
||||
├── thread.ts Thread lifecycle and exec
|
||||
├── step.ts Step operations (list/show/read/fork)
|
||||
@@ -208,4 +209,13 @@ src/
|
||||
| `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) |
|
||||
| `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash |
|
||||
| `~/.uncaged/workflow/threads.yaml` | Active thread head pointers |
|
||||
| `~/.uncaged/workflow/cas/` | Content-addressed node storage |
|
||||
| `~/.uncaged/json-cas/` | Content-addressed node storage (unified CAS store, shared with `ocas` CLI) |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `UNCAGED_CAS_DIR` | Override the global CAS directory location | `~/.uncaged/json-cas` |
|
||||
| `UNCAGED_WORKFLOW_STORAGE_ROOT` | Internal override for workflow metadata storage | `~/.uncaged/workflow` |
|
||||
| `WORKFLOW_STORAGE_ROOT` | User override for workflow metadata storage | `~/.uncaged/workflow` |
|
||||
|
||||
|
||||
@@ -8,26 +8,38 @@
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uwf": "./src/cli.ts"
|
||||
"uwf": "./dist/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"@uncaged/json-cas-fs": "^0.4.0",
|
||||
"@uncaged/workflow-agent-kit": "workspace:^",
|
||||
"@uncaged/workflow-moderator": "workspace:^",
|
||||
"@ocas/core": "^0.1.1",
|
||||
"@ocas/fs": "^0.1.1",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^",
|
||||
"commander": "^14.0.3",
|
||||
"dotenv": "^16.6.1",
|
||||
"mustache": "^4.2.0",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "bun test src/",
|
||||
"test:ci": "bun test src/"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
"@types/mustache": "^4.2.6"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/cli-workflow"
|
||||
},
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import { saveThreadsIndex } from "../store.js";
|
||||
|
||||
// ── schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$status: { type: "string" as const, enum: ["done", "failed"] },
|
||||
result: { type: "string" as const },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── fixture ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-roundtrip-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("C1: adapter JSON round-trip integration", () => {
|
||||
test("mock agent outputs JSON, CLI parses it and updates thread head in CAS", async () => {
|
||||
// 1. Set up CAS store with workflow, start node, and output schema
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
|
||||
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-roundtrip",
|
||||
description: "roundtrip integration test",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker role",
|
||||
goal: "Do work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: outputSchemaHash,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "Do the work", location: null } },
|
||||
worker: { done: { role: "$END", prompt: "completed", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test round-trip task",
|
||||
});
|
||||
|
||||
const threadId = "01ROUNDTRIPTEST0000000000" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: startHash });
|
||||
|
||||
// 2. Pre-create CAS nodes that the mock agent would produce
|
||||
const outputHash = await store.put(outputSchemaHash, {
|
||||
$status: "done",
|
||||
result: "test-ok",
|
||||
});
|
||||
|
||||
// Use text schema for detail (simple placeholder)
|
||||
const detailHash = await store.put(schemas.text, "mock detail");
|
||||
|
||||
const startedAtMs = 1716600000000;
|
||||
const completedAtMs = 1716600001500;
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-mock",
|
||||
edgePrompt: "Do the work",
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
// 3. Create a minimal mock agent shell script that just outputs JSON
|
||||
// The step node is already in CAS — the agent just needs to print the JSON line
|
||||
const mockAgentPath = join(tmpDir, "mock-agent.sh");
|
||||
const adapterJson = JSON.stringify({
|
||||
stepHash,
|
||||
detailHash,
|
||||
role: "worker",
|
||||
frontmatter: { $status: "done", result: "test-ok" },
|
||||
body: "",
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
});
|
||||
await writeFile(mockAgentPath, `#!/bin/sh\necho '${adapterJson}'\n`, { mode: 0o755 });
|
||||
|
||||
// 4. Write config.yaml
|
||||
const configPath = join(tmpDir, "config.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
|
||||
);
|
||||
|
||||
// 5. Run CLI with agent override pointing to our mock
|
||||
const cliPath = join(import.meta.dirname, "..", "cli.js");
|
||||
let stdout: string;
|
||||
let stderr: string;
|
||||
let exitCode: number;
|
||||
|
||||
try {
|
||||
stdout = execFileSync(
|
||||
"bun",
|
||||
["run", cliPath, "thread", "exec", threadId, "--agent", mockAgentPath],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
WORKFLOW_STORAGE_ROOT: tmpDir,
|
||||
UNCAGED_CAS_DIR: casDir,
|
||||
},
|
||||
cwd: tmpDir,
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
stderr = "";
|
||||
exitCode = 0;
|
||||
} catch (e: unknown) {
|
||||
const err = e as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
status?: number;
|
||||
};
|
||||
stdout = err.stdout ?? "";
|
||||
stderr = err.stderr ?? "";
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
|
||||
// 6. Verify
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`CLI exited with code ${exitCode}\nstdout: ${stdout}\nstderr: ${stderr}`);
|
||||
}
|
||||
|
||||
// Parse CLI output
|
||||
const cliOutput = JSON.parse(stdout.trim());
|
||||
expect(cliOutput).toHaveProperty("thread", threadId);
|
||||
expect(cliOutput).toHaveProperty("head", stepHash);
|
||||
expect(cliOutput.head).toMatch(/^[0-9A-HJ-NP-TV-Z]{13}$/);
|
||||
|
||||
// Verify the CAS step node exists and has correct metadata
|
||||
const storeAfter = createFsStore(casDir);
|
||||
const stepNode = storeAfter.get(cliOutput.head as CasRef);
|
||||
expect(stepNode).not.toBeNull();
|
||||
|
||||
const payload = stepNode!.payload as StepNodePayload;
|
||||
expect(payload.role).toBe("worker");
|
||||
expect(payload.agent).toBe("uwf-mock");
|
||||
expect(payload.startedAtMs).toBe(1716600000000);
|
||||
expect(payload.completedAtMs).toBe(1716600001500);
|
||||
expect(payload.output).toBe(outputHash);
|
||||
expect(payload.detail).toBe(detailHash);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,27 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdCasPutText } from "../commands/cas.js";
|
||||
|
||||
let storageRoot: string;
|
||||
let casDir: string;
|
||||
let uwfPath: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = join(
|
||||
tmpdir(),
|
||||
`uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
casDir = join(storageRoot, "cas");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
await mkdir(casDir, { recursive: true });
|
||||
|
||||
// Set UNCAGED_CAS_DIR for this test
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
// Find the uwf CLI path
|
||||
uwfPath = join(__dirname, "../../src/cli.ts");
|
||||
@@ -21,6 +29,13 @@ beforeEach(async () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
|
||||
// Restore original environment
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
type ExecResult = {
|
||||
@@ -32,7 +47,11 @@ type ExecResult = {
|
||||
function execUwf(args: string[]): ExecResult {
|
||||
try {
|
||||
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
|
||||
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
|
||||
env: {
|
||||
...process.env,
|
||||
WORKFLOW_STORAGE_ROOT: storageRoot,
|
||||
UNCAGED_CAS_DIR: casDir,
|
||||
},
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdCasHas, cmdCasPutText } from "../commands/cas.js";
|
||||
|
||||
let storageRoot: string;
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
cmdConfigGet,
|
||||
cmdConfigList,
|
||||
cmdConfigSet,
|
||||
getConfigPath,
|
||||
getNestedValue,
|
||||
maskApiKeys,
|
||||
parseDotPath,
|
||||
setNestedValue,
|
||||
} from "../commands/config.js";
|
||||
|
||||
describe("config command", () => {
|
||||
// Helper function to create a test config
|
||||
function createTestConfig(tempDir: string, content: string): string {
|
||||
const configPath = getConfigPath(tempDir);
|
||||
writeFileSync(configPath, content, "utf8");
|
||||
return configPath;
|
||||
}
|
||||
|
||||
// Sample test config
|
||||
const sampleConfig = `providers:
|
||||
dashscope:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: sk-test-dashscope-key
|
||||
openai:
|
||||
baseUrl: https://api.openai.com/v1
|
||||
apiKey: sk-test-openai-key
|
||||
models:
|
||||
default:
|
||||
provider: dashscope
|
||||
name: qwen-max
|
||||
gpt4:
|
||||
provider: openai
|
||||
name: gpt-4
|
||||
agents:
|
||||
hermes:
|
||||
command: uwf-hermes
|
||||
args:
|
||||
- --provider
|
||||
- dashscope
|
||||
claude-code:
|
||||
command: claude-code
|
||||
args:
|
||||
- --profile
|
||||
- work
|
||||
defaultAgent: hermes
|
||||
defaultModel: default
|
||||
`;
|
||||
|
||||
describe("helper functions", () => {
|
||||
describe("parseDotPath", () => {
|
||||
test("splits dot notation correctly", () => {
|
||||
expect(parseDotPath("a.b.c")).toEqual(["a", "b", "c"]);
|
||||
expect(parseDotPath("defaultAgent")).toEqual(["defaultAgent"]);
|
||||
expect(parseDotPath("providers.dashscope.baseUrl")).toEqual([
|
||||
"providers",
|
||||
"dashscope",
|
||||
"baseUrl",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNestedValue", () => {
|
||||
test("traverses nested objects", () => {
|
||||
const obj = {
|
||||
a: { b: { c: "value" } },
|
||||
x: "simple",
|
||||
};
|
||||
expect(getNestedValue(obj, ["a", "b", "c"])).toBe("value");
|
||||
expect(getNestedValue(obj, ["x"])).toBe("simple");
|
||||
});
|
||||
|
||||
test("returns undefined for non-existent paths", () => {
|
||||
const obj = { a: { b: "value" } };
|
||||
expect(getNestedValue(obj, ["a", "c"])).toBeUndefined();
|
||||
expect(getNestedValue(obj, ["x", "y"])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setNestedValue", () => {
|
||||
test("creates intermediate objects and sets value", () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, ["a", "b", "c"], "value");
|
||||
expect(obj).toEqual({ a: { b: { c: "value" } } });
|
||||
});
|
||||
|
||||
test("preserves existing values", () => {
|
||||
const obj: Record<string, unknown> = { a: { x: "keep" } };
|
||||
setNestedValue(obj, ["a", "b"], "new");
|
||||
expect(obj).toEqual({ a: { x: "keep", b: "new" } });
|
||||
});
|
||||
|
||||
test("overwrites existing value at path", () => {
|
||||
const obj: Record<string, unknown> = { a: { b: "old" } };
|
||||
setNestedValue(obj, ["a", "b"], "new");
|
||||
expect(obj).toEqual({ a: { b: "new" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskApiKeys", () => {
|
||||
test("deep clones and masks all apiKey values in providers", () => {
|
||||
const config = {
|
||||
providers: {
|
||||
dashscope: {
|
||||
baseUrl: "https://example.com",
|
||||
apiKey: "sk-test-key-12345",
|
||||
},
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com",
|
||||
apiKey: "sk-another-secret",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
default: { provider: "dashscope" },
|
||||
},
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
expect(masked).toEqual({
|
||||
providers: {
|
||||
dashscope: {
|
||||
baseUrl: "https://example.com",
|
||||
apiKey: "***MASKED***",
|
||||
},
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com",
|
||||
apiKey: "***MASKED***",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
default: { provider: "dashscope" },
|
||||
},
|
||||
});
|
||||
// Ensure it's a deep clone
|
||||
expect(masked).not.toBe(config);
|
||||
});
|
||||
|
||||
test("handles config without providers", () => {
|
||||
const config = { models: { default: { provider: "test" } } };
|
||||
const masked = maskApiKeys(config);
|
||||
expect(masked).toEqual(config);
|
||||
});
|
||||
|
||||
test("does not mask non-provider apiKey fields", () => {
|
||||
const config = {
|
||||
apiKey: "root-level-key",
|
||||
providers: {
|
||||
dashscope: { apiKey: "sk-secret" },
|
||||
},
|
||||
models: {
|
||||
default: { provider: "dashscope" },
|
||||
},
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
// Root-level apiKey should NOT be masked
|
||||
expect(masked.apiKey).toBe("root-level-key");
|
||||
// Provider apiKey SHOULD be masked
|
||||
const providers = masked.providers as Record<string, Record<string, unknown>>;
|
||||
expect(providers.dashscope.apiKey).toBe("***MASKED***");
|
||||
});
|
||||
|
||||
test("handles empty provider object", () => {
|
||||
const config = {
|
||||
providers: { dashscope: {} },
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
expect(masked).toEqual({ providers: { dashscope: {} } });
|
||||
});
|
||||
|
||||
test("handles provider with null apiKey", () => {
|
||||
const config = {
|
||||
providers: {
|
||||
dashscope: { apiKey: null, baseUrl: "https://example.com" },
|
||||
},
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
const providers = masked.providers as Record<string, Record<string, unknown>>;
|
||||
expect(providers.dashscope.apiKey).toBe("***MASKED***");
|
||||
expect(providers.dashscope.baseUrl).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdConfigList", () => {
|
||||
test("returns full config when file exists", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigList(tempDir);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe("object");
|
||||
expect(result).toHaveProperty("providers");
|
||||
expect(result).toHaveProperty("models");
|
||||
expect(result).toHaveProperty("agents");
|
||||
expect(result).toHaveProperty("defaultAgent");
|
||||
expect(result).toHaveProperty("defaultModel");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("masks all apiKey values in providers section", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = (await cmdConfigList(tempDir)) as Record<string, unknown>;
|
||||
const providers = result.providers as Record<string, unknown>;
|
||||
const dashscope = providers.dashscope as Record<string, unknown>;
|
||||
const openai = providers.openai as Record<string, unknown>;
|
||||
expect(dashscope.apiKey).toBe("***MASKED***");
|
||||
expect(openai.apiKey).toBe("***MASKED***");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error when config file doesn't exist", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
await expect(cmdConfigList(tempDir)).rejects.toThrow();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns empty object when config file is empty", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, "");
|
||||
const result = await cmdConfigList(tempDir);
|
||||
expect(result).toEqual({});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error when config file is invalid YAML", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, "invalid: yaml: [broken");
|
||||
await expect(cmdConfigList(tempDir)).rejects.toThrow();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdConfigGet", () => {
|
||||
test("retrieves top-level string value (defaultAgent)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigGet(tempDir, "defaultAgent");
|
||||
expect(result).toBe("hermes");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("retrieves top-level string value (defaultModel)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigGet(tempDir, "defaultModel");
|
||||
expect(result).toBe("default");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("retrieves nested object (providers.dashscope)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigGet(tempDir, "providers.dashscope");
|
||||
expect(result).toEqual({
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test-dashscope-key",
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("retrieves deeply nested string (providers.dashscope.baseUrl)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl");
|
||||
expect(result).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("retrieves nested string in models (models.default.provider)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigGet(tempDir, "models.default.provider");
|
||||
expect(result).toBe("dashscope");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("retrieves array value (agents.hermes.args)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigGet(tempDir, "agents.hermes.args");
|
||||
expect(result).toEqual(["--provider", "dashscope"]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error when key doesn't exist", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigGet(tempDir, "nonexistent.key")).rejects.toThrow(/Key not found/);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error when config file doesn't exist", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
await expect(cmdConfigGet(tempDir, "defaultAgent")).rejects.toThrow();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error when accessing property on non-object", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigGet(tempDir, "defaultAgent.foo")).rejects.toThrow();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdConfigSet", () => {
|
||||
test("sets top-level string value (defaultAgent)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigSet(tempDir, "defaultAgent", "claude-code");
|
||||
expect(result).toEqual({ key: "defaultAgent", value: "claude-code" });
|
||||
// Verify it was written
|
||||
const updated = await cmdConfigGet(tempDir, "defaultAgent");
|
||||
expect(updated).toBe("claude-code");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("sets nested string value (providers.dashscope.baseUrl)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const newUrl = "https://new-api.example.com/v1";
|
||||
const result = await cmdConfigSet(tempDir, "providers.dashscope.baseUrl", newUrl);
|
||||
expect(result).toEqual({
|
||||
key: "providers.dashscope.baseUrl",
|
||||
value: newUrl,
|
||||
});
|
||||
// Verify it was written
|
||||
const updated = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl");
|
||||
expect(updated).toBe(newUrl);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("creates new nested path (providers.newprovider.baseUrl)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const newUrl = "https://new-provider.com/v1";
|
||||
const result = await cmdConfigSet(tempDir, "providers.newprovider.baseUrl", newUrl);
|
||||
expect(result).toEqual({
|
||||
key: "providers.newprovider.baseUrl",
|
||||
value: newUrl,
|
||||
});
|
||||
// Verify it was created
|
||||
const updated = await cmdConfigGet(tempDir, "providers.newprovider.baseUrl");
|
||||
expect(updated).toBe(newUrl);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("sets array value for args key with valid JSON array", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const newArgs = '["--new", "--flags"]';
|
||||
const result = await cmdConfigSet(tempDir, "agents.hermes.args", newArgs);
|
||||
expect(result).toEqual({
|
||||
key: "agents.hermes.args",
|
||||
value: ["--new", "--flags"],
|
||||
});
|
||||
// Verify it was written
|
||||
const updated = await cmdConfigGet(tempDir, "agents.hermes.args");
|
||||
expect(updated).toEqual(["--new", "--flags"]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves existing config values when updating one key", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "defaultAgent", "claude-code");
|
||||
// Verify other values are preserved
|
||||
const defaultModel = await cmdConfigGet(tempDir, "defaultModel");
|
||||
expect(defaultModel).toBe("default");
|
||||
const dashscopeUrl = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl");
|
||||
expect(dashscopeUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("creates config file if it doesn't exist", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
const result = await cmdConfigSet(tempDir, "defaultAgent", "hermes");
|
||||
expect(result).toEqual({ key: "defaultAgent", value: "hermes" });
|
||||
// Verify file was created
|
||||
const configPath = getConfigPath(tempDir);
|
||||
const content = readFileSync(configPath, "utf8");
|
||||
expect(content).toContain("defaultAgent: hermes");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error when setting property on non-object", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "defaultAgent.foo", "bar")).rejects.toThrow();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error when array value is invalid JSON for args key", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(
|
||||
cmdConfigSet(tempDir, "agents.hermes.args", "[invalid json"),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("sets deeply nested model config (models.gpt4.provider)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigSet(tempDir, "models.gpt4.provider", "new-provider");
|
||||
expect(result).toEqual({
|
||||
key: "models.gpt4.provider",
|
||||
value: "new-provider",
|
||||
});
|
||||
// Verify it was written
|
||||
const updated = await cmdConfigGet(tempDir, "models.gpt4.provider");
|
||||
expect(updated).toBe("new-provider");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("sets agent command (agents.claude-code.command)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
const result = await cmdConfigSet(tempDir, "agents.claude-code.command", "new-command");
|
||||
expect(result).toEqual({
|
||||
key: "agents.claude-code.command",
|
||||
value: "new-command",
|
||||
});
|
||||
// Verify it was written
|
||||
const updated = await cmdConfigGet(tempDir, "agents.claude-code.command");
|
||||
expect(updated).toBe("new-command");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdConfigSet validation", () => {
|
||||
test("rejects unknown top-level key", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "unknownKey", "value")).rejects.toThrow(
|
||||
/Unknown config key.*unknownKey/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown nested key in providers", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(
|
||||
cmdConfigSet(tempDir, "providers.myProvider.unknownField", "value"),
|
||||
).rejects.toThrow(/Unknown field.*unknownField.*providers/);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown nested key in models", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "models.default.invalidField", "value")).rejects.toThrow(
|
||||
/Unknown field.*invalidField.*models/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown nested key in agents", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "agents.hermes.badField", "value")).rejects.toThrow(
|
||||
/Unknown field.*badField.*agents/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects nested path on scalar key (defaultAgent)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "defaultAgent.foo", "value")).rejects.toThrow(
|
||||
/defaultAgent.*scalar|Cannot set property/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects nested path on scalar key (defaultModel)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "defaultModel.bar", "value")).rejects.toThrow(
|
||||
/defaultModel.*scalar|Cannot set property/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects incomplete nested path (providers without field)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "providers.myProvider", "value")).rejects.toThrow(
|
||||
/incomplete path|must specify a field/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects incomplete nested path (models without field)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "models.myModel", "value")).rejects.toThrow(
|
||||
/incomplete path|must specify a field/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects incomplete nested path (agents without field)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "agents.myAgent", "value")).rejects.toThrow(
|
||||
/incomplete path|must specify a field/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("allows valid nested keys in providers", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "providers.newprovider.baseUrl", "https://example.com");
|
||||
await cmdConfigSet(tempDir, "providers.newprovider.apiKey", "sk-test");
|
||||
const baseUrl = await cmdConfigGet(tempDir, "providers.newprovider.baseUrl");
|
||||
const apiKey = await cmdConfigGet(tempDir, "providers.newprovider.apiKey");
|
||||
expect(baseUrl).toBe("https://example.com");
|
||||
expect(apiKey).toBe("sk-test");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("allows valid nested keys in models", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "models.gpt4.provider", "openai");
|
||||
await cmdConfigSet(tempDir, "models.gpt4.name", "gpt-4o");
|
||||
const provider = await cmdConfigGet(tempDir, "models.gpt4.provider");
|
||||
const name = await cmdConfigGet(tempDir, "models.gpt4.name");
|
||||
expect(provider).toBe("openai");
|
||||
expect(name).toBe("gpt-4o");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("allows valid nested keys in agents", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "agents.hermes.command", "uwf-hermes");
|
||||
await cmdConfigSet(tempDir, "agents.hermes.args", '["--flag"]');
|
||||
const command = await cmdConfigGet(tempDir, "agents.hermes.command");
|
||||
const args = await cmdConfigGet(tempDir, "agents.hermes.args");
|
||||
expect(command).toBe("uwf-hermes");
|
||||
expect(args).toEqual(["--flag"]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("agentOverrides — accepts valid 3-segment path", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "agentOverrides.solve-issue.planner", "claude-code");
|
||||
const value = await cmdConfigGet(tempDir, "agentOverrides.solve-issue.planner");
|
||||
expect(value).toBe("claude-code");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("agentOverrides — rejects incomplete path (2 segments)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "agentOverrides.solve-issue", "hermes")).rejects.toThrow(
|
||||
/incomplete path|must specify a field/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("modelOverrides — accepts valid 2-segment path", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "modelOverrides.extract", "gpt4");
|
||||
const value = await cmdConfigGet(tempDir, "modelOverrides.extract");
|
||||
expect(value).toBe("gpt4");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("modelOverrides — rejects incomplete path (1 segment only)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "modelOverrides", "gpt4")).rejects.toThrow(
|
||||
/incomplete path|must specify a field/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown top-level key (regression)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "randomKey", "value")).rejects.toThrow(
|
||||
/Unknown config key/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("no legacy apiKeyEnv references", () => {
|
||||
test("config.ts has no references to apiKeyEnv", () => {
|
||||
const configSource = readFileSync(
|
||||
join(__dirname, "..", "..", "src", "commands", "config.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(configSource).not.toContain("apiKeyEnv");
|
||||
});
|
||||
|
||||
test("config.test.ts has no references to apiKeyEnv (except this test)", () => {
|
||||
const testSource = readFileSync(__filename, "utf8");
|
||||
// Remove this test block's own mentions before checking
|
||||
const withoutThisTest = testSource.replace(
|
||||
/describe\("no legacy apiKeyEnv references"[\s\S]*$/,
|
||||
"",
|
||||
);
|
||||
expect(withoutThisTest).not.toContain("apiKeyEnv");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { createMarker, deleteMarker } from "../background/index.js";
|
||||
import { cmdThreadList, cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
loadThreadsIndex,
|
||||
saveThreadsIndex,
|
||||
} from "../store.js";
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$status: { type: "string" as const },
|
||||
},
|
||||
};
|
||||
|
||||
const SIMPLE_WORKFLOW_YAML = `
|
||||
name: test-current-role
|
||||
description: Test workflow for currentRole
|
||||
roles:
|
||||
roleA:
|
||||
description: First role
|
||||
goal: Do A
|
||||
capabilities: ["coding"]
|
||||
procedure: Do A
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string, enum: ["ready", "not-ready"] }
|
||||
roleB:
|
||||
description: Second role
|
||||
goal: Do B
|
||||
capabilities: ["coding"]
|
||||
procedure: Do B
|
||||
output: |
|
||||
$status: "done"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: roleA
|
||||
prompt: "Do A"
|
||||
location: null
|
||||
roleA:
|
||||
ready:
|
||||
role: roleB
|
||||
prompt: "Do B"
|
||||
location: null
|
||||
not-ready:
|
||||
role: roleA
|
||||
prompt: "Try again"
|
||||
location: null
|
||||
roleB:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const CONDITIONAL_WORKFLOW_YAML = `
|
||||
name: test-conditional-role
|
||||
description: Conditional routing workflow
|
||||
roles:
|
||||
roleA:
|
||||
description: First role
|
||||
goal: Do A
|
||||
capabilities: ["coding"]
|
||||
procedure: Do A
|
||||
output: |
|
||||
$status: "pass"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string, enum: ["pass", "fail"] }
|
||||
roleB:
|
||||
description: Pass role
|
||||
goal: Do B
|
||||
capabilities: ["coding"]
|
||||
procedure: Do B
|
||||
output: |
|
||||
$status: "done"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
roleC:
|
||||
description: Fail role
|
||||
goal: Do C
|
||||
capabilities: ["coding"]
|
||||
procedure: Do C
|
||||
output: |
|
||||
$status: "done"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: roleA
|
||||
prompt: "Do A"
|
||||
location: null
|
||||
roleA:
|
||||
pass:
|
||||
role: roleB
|
||||
prompt: "Do B (pass)"
|
||||
location: null
|
||||
fail:
|
||||
role: roleC
|
||||
prompt: "Do C (fail)"
|
||||
location: null
|
||||
roleB:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
roleC:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const SINGLE_ROLE_WORKFLOW_YAML = `
|
||||
name: test-single-role
|
||||
description: Single role that goes to END
|
||||
roles:
|
||||
worker:
|
||||
description: Worker
|
||||
goal: Work
|
||||
capabilities: ["coding"]
|
||||
procedure: Work
|
||||
output: |
|
||||
$status: "done"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: worker
|
||||
prompt: "Work"
|
||||
location: null
|
||||
worker:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
/** Helper: insert a completed step node after the current head. */
|
||||
async function insertStepNode(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
outputPayload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headEntry = index[threadId];
|
||||
if (headEntry === undefined) throw new Error(`thread ${threadId} not in index`);
|
||||
const head = headEntry.head;
|
||||
|
||||
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
|
||||
const outputHash = await uwf.store.put(outputSchemaHash, outputPayload);
|
||||
|
||||
// Use text schema for detail (simple placeholder)
|
||||
const detailHash = await uwf.store.put(uwf.schemas.text, "detail-placeholder");
|
||||
|
||||
// Resolve start hash from head
|
||||
const headNode = uwf.store.get(head);
|
||||
if (headNode === null) throw new Error(`head ${head} not found`);
|
||||
const isStart = headNode.type === uwf.schemas.startNode;
|
||||
const startHash = isStart ? head : (headNode.payload as { start: CasRef }).start;
|
||||
|
||||
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: isStart ? null : head,
|
||||
role,
|
||||
prompt: `Do ${role}`,
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
})) as CasRef;
|
||||
|
||||
index[threadId] = { head: stepHash, suspendedRole: null, suspendMessage: null };
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
}
|
||||
|
||||
describe("currentRole field", () => {
|
||||
let tmpDir: string;
|
||||
let storageRoot: string;
|
||||
let casDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
async function setup() {
|
||||
tmpDir = join(
|
||||
tmpdir(),
|
||||
`uwf-test-current-role-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
storageRoot = join(tmpDir, "storage");
|
||||
casDir = join(tmpDir, "cas");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
await mkdir(casDir, { recursive: true });
|
||||
|
||||
// Set UNCAGED_CAS_DIR for this test
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (tmpDir) {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
// Restore original environment
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
}
|
||||
}
|
||||
|
||||
// T1: idle at start — currentRole = first role from graph
|
||||
test("thread show — idle at start returns first role as currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
|
||||
expect(result.status).toBe("idle");
|
||||
expect(result.currentRole).toBe("roleA");
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T2: idle after one step — currentRole = next role
|
||||
test("thread show — idle after step returns next role as currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
|
||||
await insertStepNode(storageRoot, thread as ThreadId, "roleA", { $status: "ready" });
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
|
||||
expect(result.status).toBe("idle");
|
||||
expect(result.currentRole).toBe("roleB");
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T3: completed → currentRole = null
|
||||
test("thread show — completed thread returns null currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
const tid = thread as ThreadId;
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[tid]!.head;
|
||||
delete index[tid];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: tid,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, tid);
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.currentRole).toBe(null);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T4: cancelled → currentRole = null
|
||||
test("thread show — cancelled thread returns null currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
const tid = thread as ThreadId;
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[tid]!.head;
|
||||
delete index[tid];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: tid,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "cancelled",
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, tid);
|
||||
expect(result.status).toBe("cancelled");
|
||||
expect(result.currentRole).toBe(null);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T5: running → currentRole = role being executed
|
||||
test("thread show — running thread returns current role", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
const tid = thread as ThreadId;
|
||||
|
||||
await createMarker(storageRoot, {
|
||||
thread: tid,
|
||||
workflow,
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await cmdThreadShow(storageRoot, tid);
|
||||
expect(result.status).toBe("running");
|
||||
expect(result.currentRole).toBe("roleA");
|
||||
} finally {
|
||||
await deleteMarker(storageRoot, tid);
|
||||
}
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T6: thread list — mixed statuses with correct currentRole
|
||||
test("thread list — returns correct currentRole for each status", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// idle thread
|
||||
const idle = await cmdThreadStart(storageRoot, wf, "idle", tmpDir);
|
||||
const idleId = idle.thread as ThreadId;
|
||||
|
||||
// completed thread
|
||||
const comp = await cmdThreadStart(storageRoot, wf, "completed", tmpDir);
|
||||
const compId = comp.thread as ThreadId;
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const compHead = index[compId]!.head;
|
||||
delete index[compId];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: compId,
|
||||
workflow: comp.workflow,
|
||||
head: compHead,
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
|
||||
|
||||
const idleItem = list.find((i) => i.thread === idleId);
|
||||
expect(idleItem).toBeDefined();
|
||||
expect(idleItem!.currentRole).toBe("roleA");
|
||||
|
||||
const compItem = list.find((i) => i.thread === compId);
|
||||
expect(compItem).toBeDefined();
|
||||
expect(compItem!.currentRole).toBe(null);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T7: thread list — idle at start has correct currentRole
|
||||
test("thread list — idle thread at start has correct currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
|
||||
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
|
||||
const item = list.find((i) => i.thread === (thread as ThreadId));
|
||||
expect(item).toBeDefined();
|
||||
expect(item!.currentRole).toBe("roleA");
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T8: conditional routing — $status=pass vs fail
|
||||
test("thread show — conditional routing selects correct next role", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-conditional-role.yaml");
|
||||
await writeFile(wf, CONDITIONAL_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// pass path
|
||||
const t1 = await cmdThreadStart(storageRoot, wf, "pass test", tmpDir);
|
||||
await insertStepNode(storageRoot, t1.thread as ThreadId, "roleA", { $status: "pass" });
|
||||
const r1 = await cmdThreadShow(storageRoot, t1.thread as ThreadId);
|
||||
expect(r1.currentRole).toBe("roleB");
|
||||
|
||||
// fail path
|
||||
const t2 = await cmdThreadStart(storageRoot, wf, "fail test", tmpDir);
|
||||
await insertStepNode(storageRoot, t2.thread as ThreadId, "roleA", { $status: "fail" });
|
||||
const r2 = await cmdThreadShow(storageRoot, t2.thread as ThreadId);
|
||||
expect(r2.currentRole).toBe("roleC");
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T9: next role is $END → currentRole = null
|
||||
test("thread show — when next is $END, currentRole is null", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-single-role.yaml");
|
||||
await writeFile(wf, SINGLE_ROLE_WORKFLOW_YAML, "utf8");
|
||||
|
||||
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
// worker → _ maps to $END
|
||||
await insertStepNode(storageRoot, thread as ThreadId, "worker", {});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
|
||||
expect(result.currentRole).toBe(null);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import { createIncludeTag } from "../include.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "include-tag-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("!include tag", () => {
|
||||
test("includes .md file as string", async () => {
|
||||
await writeFile(join(tmpDir, "prompt.md"), "You are an analyst.");
|
||||
const yaml = "system: !include prompt.md";
|
||||
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
|
||||
expect(result.system).toBe("You are an analyst.");
|
||||
});
|
||||
|
||||
test("includes .json file as parsed object", async () => {
|
||||
await writeFile(join(tmpDir, "schema.json"), '{"type":"object","properties":{}}');
|
||||
const yaml = "outputSchema: !include schema.json";
|
||||
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
|
||||
expect(result.outputSchema).toEqual({ type: "object", properties: {} });
|
||||
});
|
||||
|
||||
test("includes .yaml file as parsed object", async () => {
|
||||
await writeFile(join(tmpDir, "config.yaml"), "key: value\nlist:\n - a\n - b");
|
||||
const yaml = "config: !include config.yaml";
|
||||
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
|
||||
expect(result.config).toEqual({ key: "value", list: ["a", "b"] });
|
||||
});
|
||||
|
||||
test("resolves relative subdirectory paths", async () => {
|
||||
const subdir = join(tmpDir, "roles");
|
||||
await mkdir(subdir, { recursive: true });
|
||||
await writeFile(join(subdir, "analyst.md"), "Analyze data.");
|
||||
const yaml = "system: !include roles/analyst.md";
|
||||
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
|
||||
expect(result.system).toBe("Analyze data.");
|
||||
});
|
||||
|
||||
test("throws on missing file", () => {
|
||||
const yaml = "system: !include nonexistent.md";
|
||||
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow();
|
||||
});
|
||||
|
||||
test("includes .txt file as string", async () => {
|
||||
await writeFile(join(tmpDir, "note.txt"), "Hello world");
|
||||
const yaml = "note: !include note.txt";
|
||||
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
|
||||
expect(result.note).toBe("Hello world");
|
||||
});
|
||||
|
||||
test("blocks path traversal with ../", async () => {
|
||||
const yaml = "secret: !include ../../etc/passwd";
|
||||
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow(
|
||||
/path traversal blocked/,
|
||||
);
|
||||
});
|
||||
|
||||
test("blocks absolute path traversal", async () => {
|
||||
const yaml = "secret: !include /etc/passwd";
|
||||
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow(
|
||||
/path traversal blocked/,
|
||||
);
|
||||
});
|
||||
|
||||
test("supports nested !include in yaml files", async () => {
|
||||
const subdir = join(tmpDir, "parts");
|
||||
await mkdir(subdir, { recursive: true });
|
||||
await writeFile(join(subdir, "inner.md"), "nested content");
|
||||
await writeFile(join(tmpDir, "outer.yaml"), "value: !include parts/inner.md");
|
||||
const yaml = "config: !include outer.yaml";
|
||||
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
|
||||
expect(result.config).toEqual({ value: "nested content" });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdLogClean, cmdLogList, cmdLogShow } from "../commands/log.js";
|
||||
|
||||
let storageRoot: string;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { evaluate } from "../moderator/evaluate.js";
|
||||
|
||||
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||
$START: {
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task.", location: null },
|
||||
},
|
||||
planner: {
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null },
|
||||
},
|
||||
developer: {
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}", location: null },
|
||||
},
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done.", location: null },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
describe("evaluate", () => {
|
||||
test("$START → first role (unit status _)", () => {
|
||||
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
role: "planner",
|
||||
prompt: "Start planning from the issue in the task.",
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("status-based routing (reviewer rejected → developer)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
$status: "rejected",
|
||||
comments: "missing tests",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: missing tests", location: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("status-based routing (reviewer approved → $END)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Done.", location: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("status-based routing (needs input → $SUSPEND)", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
...solveIssueGraph,
|
||||
reviewer: {
|
||||
...solveIssueGraph.reviewer,
|
||||
needs_input: { role: "$SUSPEND", prompt: "Waiting for user input.", location: null },
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", { $status: "needs_input" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
action: "suspend",
|
||||
suspendedRole: "reviewer",
|
||||
prompt: "Waiting for user input.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("$SUSPEND prompt template renders mustache variables", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
needs_input: {
|
||||
role: "$SUSPEND",
|
||||
prompt: "Please clarify: {{{question}}}",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
$status: "needs_input",
|
||||
question: "Which API endpoint?",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
action: "suspend",
|
||||
suspendedRole: "reviewer",
|
||||
prompt: "Please clarify: Which API endpoint?",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("missing role in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "unknown-role", { $status: "_" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||
}
|
||||
});
|
||||
|
||||
test("missing status in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "pending" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||
}
|
||||
});
|
||||
|
||||
test("mustache template rendering with simple fields", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
$status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
role: "developer",
|
||||
prompt: "Implement the plan: Add auth middleware",
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("mustache does not HTML-escape prompt content", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
$status: "rejected",
|
||||
comments: 'use <T> & "Result<T, E>" types',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types', location: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("triple mustache also works for unescaped output", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}", location: null },
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
$status: "_",
|
||||
comments: "<script>alert(1)</script>",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>", location: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing $status defaults to _ (unit routing)", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
role: "developer",
|
||||
prompt: "Implement the plan: Add auth middleware",
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("mustache template with nested object paths", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: {
|
||||
role: "developer",
|
||||
prompt: "Address: {{review.comments}}",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
$status: "_",
|
||||
review: { comments: "refactor the handler" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Address: refactor the handler", location: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const originalExit = process.exit;
|
||||
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit(${code ?? 1})`);
|
||||
}) as typeof process.exit;
|
||||
|
||||
export { originalExit };
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
import {
|
||||
cmdPromptAdapter,
|
||||
cmdPromptAuthor,
|
||||
cmdPromptDeveloper,
|
||||
cmdPromptList,
|
||||
cmdPromptSetup,
|
||||
cmdPromptUsage,
|
||||
cmdPromptUser,
|
||||
} from "../commands/prompt.js";
|
||||
|
||||
describe("prompt commands", () => {
|
||||
test("prompt list returns all prompt names", () => {
|
||||
const result = cmdPromptList();
|
||||
expect(result).toBeInstanceOf(Array);
|
||||
expect(result).toContain("user");
|
||||
expect(result).toContain("author");
|
||||
expect(result).toContain("developer");
|
||||
expect(result).toContain("adapter");
|
||||
for (const name of result) {
|
||||
expect(name).toMatch(/^\S+$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("prompt user returns non-empty markdown string", () => {
|
||||
const result = cmdPromptUser();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("uwf");
|
||||
expect(result).toContain("thread");
|
||||
expect(result).toContain("workflow");
|
||||
expect(result).toContain("Quick Start");
|
||||
expect(result.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
test("prompt author returns non-empty markdown string", () => {
|
||||
const result = cmdPromptAuthor();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("frontmatter");
|
||||
expect(result).toContain("graph");
|
||||
expect(result).toContain("$START");
|
||||
expect(result).toContain("$END");
|
||||
expect(result).toContain("$status");
|
||||
expect(result.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
test("prompt developer returns non-empty markdown string", () => {
|
||||
const result = cmdPromptDeveloper();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("Monorepo");
|
||||
expect(result).toContain("CAS");
|
||||
expect(result).toContain("Biome");
|
||||
expect(result.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
test("prompt adapter returns non-empty markdown string", () => {
|
||||
const result = cmdPromptAdapter();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("createAgent");
|
||||
expect(result).toContain("AgentContext");
|
||||
expect(result).toContain("frontmatter");
|
||||
expect(result.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
test("prompt usage combines all references", () => {
|
||||
const result = cmdPromptUsage();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("User Reference");
|
||||
expect(result).toContain("Author Reference");
|
||||
expect(result).toContain("Developer Reference");
|
||||
expect(result).toContain("Adapter Reference");
|
||||
expect(result).toContain("---");
|
||||
expect(result.length).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
test("prompt setup returns setup instructions", () => {
|
||||
const result = cmdPromptSetup();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("uwf Skill Setup");
|
||||
expect(result).toContain("uwf prompt usage");
|
||||
expect(result).toContain("uwf prompt setup");
|
||||
expect(result).toContain("SKILL.md");
|
||||
expect(result).toContain("version");
|
||||
});
|
||||
|
||||
test("prompt help subcommand is suppressed", () => {
|
||||
const output = execFileSync("bun", ["src/cli.ts", "prompt", "--help"], {
|
||||
cwd: join(__dirname, "..", ".."),
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
|
||||
});
|
||||
expect(output).not.toMatch(/help\s+\[command\]/i);
|
||||
expect(output).toContain("usage");
|
||||
expect(output).toContain("setup");
|
||||
expect(output).toContain("user");
|
||||
expect(output).toContain("author");
|
||||
expect(output).toContain("developer");
|
||||
expect(output).toContain("adapter");
|
||||
expect(output).toContain("list");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { resolveHeadHash } from "../commands/shared.js";
|
||||
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("resolveHeadHash", () => {
|
||||
workflow: workflowHash,
|
||||
head: headHash,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const result = await resolveHeadHash(tmpDir, threadId);
|
||||
@@ -64,6 +65,7 @@ describe("resolveHeadHash", () => {
|
||||
workflow: workflowHash,
|
||||
head: historicalHash,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const result = await resolveHeadHash(tmpDir, threadId);
|
||||
@@ -87,18 +89,21 @@ describe("resolveHeadHash", () => {
|
||||
workflow: workflowHash,
|
||||
head: hash1,
|
||||
completedAt: Date.now() - 2000,
|
||||
reason: null,
|
||||
});
|
||||
await appendThreadHistory(tmpDir, {
|
||||
thread: threadId2,
|
||||
workflow: workflowHash,
|
||||
head: hash2,
|
||||
completedAt: Date.now() - 1000,
|
||||
reason: null,
|
||||
});
|
||||
await appendThreadHistory(tmpDir, {
|
||||
thread: threadId3,
|
||||
workflow: workflowHash,
|
||||
head: hash3,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const result = await resolveHeadHash(tmpDir, threadId2);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import { _agentNameFromBinary, _printAgentMenu, cmdSetup } from "../commands/setup.js";
|
||||
|
||||
// ─── _agentNameFromBinary ────────────────────────────────────────────────────
|
||||
|
||||
describe("_agentNameFromBinary", () => {
|
||||
test("strips uwf- prefix", () => {
|
||||
expect(_agentNameFromBinary("uwf-hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
test("strips uwf- prefix for compound names", () => {
|
||||
expect(_agentNameFromBinary("uwf-claude-code")).toBe("claude-code");
|
||||
});
|
||||
|
||||
test("returns as-is when no uwf- prefix", () => {
|
||||
expect(_agentNameFromBinary("hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
test("handles uwf-builtin", () => {
|
||||
expect(_agentNameFromBinary("uwf-builtin")).toBe("builtin");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── _printAgentMenu ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("_printAgentMenu", () => {
|
||||
test("prints known agents with labels", () => {
|
||||
const logs: string[] = [];
|
||||
spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
||||
logs.push(args.join(" "));
|
||||
});
|
||||
|
||||
_printAgentMenu(["uwf-hermes", "uwf-claude-code"]);
|
||||
|
||||
expect(logs.some((l) => l.includes("Hermes"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("Claude Code"))).toBe(true);
|
||||
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
test("prints unknown agents with binary name as label", () => {
|
||||
const logs: string[] = [];
|
||||
spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
||||
logs.push(args.join(" "));
|
||||
});
|
||||
|
||||
_printAgentMenu(["uwf-custom-agent"]);
|
||||
|
||||
expect(logs.some((l) => l.includes("uwf-custom-agent"))).toBe(true);
|
||||
|
||||
mock.restore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── cmdSetup agent config ───────────────────────────────────────────────────
|
||||
|
||||
describe("cmdSetup agent configuration", () => {
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-agent-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore();
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const baseArgs = () => ({
|
||||
provider: "testprovider",
|
||||
baseUrl: "https://api.test.com/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "test-model",
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
test("defaults to hermes agent when no agent specified", async () => {
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const result = await cmdSetup(baseArgs());
|
||||
|
||||
expect(result.defaultAgent).toBe("hermes");
|
||||
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||
expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] });
|
||||
expect(config.defaultAgent).toBe("hermes");
|
||||
});
|
||||
|
||||
test("writes specified agent as default", async () => {
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const result = await cmdSetup({ ...baseArgs(), agent: "claude-code" });
|
||||
|
||||
expect(result.defaultAgent).toBe("claude-code");
|
||||
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||
expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] });
|
||||
expect(config.defaultAgent).toBe("claude-code");
|
||||
});
|
||||
|
||||
test("preserves existing agents when adding new one", async () => {
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
// First setup with hermes
|
||||
await cmdSetup(baseArgs());
|
||||
// Second setup with claude-code
|
||||
await cmdSetup({ ...baseArgs(), agent: "claude-code" });
|
||||
|
||||
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||
expect(config.agents.hermes).toBeDefined();
|
||||
expect(config.agents["claude-code"]).toBeDefined();
|
||||
expect(config.defaultAgent).toBe("claude-code");
|
||||
});
|
||||
|
||||
test("updates defaultAgent on re-run with different agent", async () => {
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
await cmdSetup(baseArgs());
|
||||
const config1 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||
expect(config1.defaultAgent).toBe("hermes");
|
||||
|
||||
await cmdSetup({ ...baseArgs(), agent: "builtin" });
|
||||
const config2 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||
expect(config2.defaultAgent).toBe("builtin");
|
||||
});
|
||||
|
||||
test("normalizes agent name with uwf- prefix to bare name", async () => {
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-hermes" });
|
||||
|
||||
expect(result.defaultAgent).toBe("hermes");
|
||||
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||
expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] });
|
||||
expect(config.defaultAgent).toBe("hermes");
|
||||
// Verify no duplicate uwf- prefix
|
||||
expect(config.agents["uwf-hermes"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("normalizes uwf-claude-code to claude-code", async () => {
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-claude-code" });
|
||||
|
||||
expect(result.defaultAgent).toBe("claude-code");
|
||||
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||
expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] });
|
||||
expect(config.defaultAgent).toBe("claude-code");
|
||||
// Verify no duplicate uwf- prefix
|
||||
expect(config.agents["uwf-claude-code"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
_discoverAgents,
|
||||
_isBackspace,
|
||||
@@ -178,7 +178,7 @@ describe("_isBackspace", () => {
|
||||
|
||||
describe("_printProviderMenu", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
const providers = [
|
||||
@@ -188,7 +188,7 @@ describe("_printProviderMenu", () => {
|
||||
|
||||
test("prints correct number of lines (one per provider + custom)", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printProviderMenu(providers);
|
||||
@@ -198,7 +198,7 @@ describe("_printProviderMenu", () => {
|
||||
|
||||
test("custom option number = providers.length + 1", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printProviderMenu(providers);
|
||||
@@ -208,7 +208,7 @@ describe("_printProviderMenu", () => {
|
||||
|
||||
test("each provider line contains its label and baseUrl", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printProviderMenu(providers);
|
||||
@@ -294,12 +294,12 @@ describe("_resolveModelChoice", () => {
|
||||
|
||||
describe("_printModelMenu", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
test("prints all models — each model name appears in output", () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
output.push(msg);
|
||||
});
|
||||
const models = ["model-a", "model-b", "model-c"];
|
||||
@@ -312,7 +312,7 @@ describe("_printModelMenu", () => {
|
||||
|
||||
test("single column when termCols is very small", () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
output.push(msg);
|
||||
});
|
||||
_printModelMenu(["a", "b", "c"], 1);
|
||||
@@ -322,7 +322,7 @@ describe("_printModelMenu", () => {
|
||||
|
||||
test("wide terminal fits multiple columns", () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
output.push(msg);
|
||||
});
|
||||
const models = Array.from({ length: 6 }, (_, i) => `m${i}`);
|
||||
@@ -338,12 +338,12 @@ describe("_printModelMenu", () => {
|
||||
|
||||
describe("_printValidationResult", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
test("ok=true prints success message containing '✓'", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printValidationResult({ ok: true, error: null });
|
||||
@@ -352,7 +352,7 @@ describe("_printValidationResult", () => {
|
||||
|
||||
test("ok=false prints warning message containing '⚠'", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||
@@ -361,7 +361,7 @@ describe("_printValidationResult", () => {
|
||||
|
||||
test("ok=false includes the error string in output", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { cmdSetup, validateModel } from "../commands/setup.js";
|
||||
|
||||
describe("validateModel", () => {
|
||||
@@ -10,18 +10,18 @@ describe("validateModel", () => {
|
||||
const MODEL = "test-model";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
test("success path — returns ok on 200", async () => {
|
||||
const mockFetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result).toEqual({ ok: true, value: undefined });
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, opts] = mockFetch.mock.calls[0]!;
|
||||
expect(url).toBe(`${BASE_URL}/chat/completions`);
|
||||
@@ -37,7 +37,7 @@ describe("validateModel", () => {
|
||||
});
|
||||
|
||||
test("HTTP 401 — returns error containing 401", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("validateModel", () => {
|
||||
});
|
||||
|
||||
test("HTTP 404 — returns error containing 404", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Not Found", { status: 404, statusText: "Not Found" }),
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("validateModel", () => {
|
||||
|
||||
test("network timeout — returns error mentioning timeout", async () => {
|
||||
const err = new DOMException("signal timed out", "AbortError");
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(err);
|
||||
spyOn(globalThis, "fetch").mockRejectedValue(err);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("validateModel", () => {
|
||||
});
|
||||
|
||||
test("network error (DNS/connection) — returns error mentioning connectivity", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
|
||||
spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
@@ -86,9 +86,9 @@ describe("validateModel", () => {
|
||||
});
|
||||
|
||||
test("request body correctness", async () => {
|
||||
const mockFetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
await validateModel(BASE_URL, API_KEY, "my-special-model");
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("cmdSetup with validation", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
mock.restore();
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -122,20 +122,17 @@ describe("cmdSetup with validation", () => {
|
||||
});
|
||||
|
||||
test("includes validation result on success", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const result = await cmdSetup(setupArgs());
|
||||
|
||||
expect(result.validation).toEqual({ ok: true, value: undefined });
|
||||
// Config files should still be written
|
||||
// Config file should still be written
|
||||
expect(result.configPath).toBeTruthy();
|
||||
expect(result.envPath).toBeTruthy();
|
||||
});
|
||||
|
||||
test("includes validation failure — config still saved", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
||||
);
|
||||
|
||||
@@ -143,8 +140,7 @@ describe("cmdSetup with validation", () => {
|
||||
|
||||
expect(result.validation).toBeDefined();
|
||||
expect((result.validation as { ok: boolean }).ok).toBe(false);
|
||||
// Config files should still be written despite validation failure
|
||||
// Config file should still be written despite validation failure
|
||||
expect(result.configPath).toBeTruthy();
|
||||
expect(result.envPath).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
/**
|
||||
* Test: Issue #474 - tea pr create fails in git worktree directories
|
||||
*
|
||||
* This test verifies that the solve-issue workflow's committer role
|
||||
* includes the --repo flag when running tea pr create, which fixes
|
||||
* the "path segment [0] is empty" error in worktree directories.
|
||||
* uses direct Gitea API calls via curl instead of tea pr create,
|
||||
* which fixes the "path segment [0] is empty" error in worktree directories.
|
||||
*/
|
||||
|
||||
describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
// Navigate up from packages/cli-workflow to repo root
|
||||
const workflowPath = join(process.cwd(), "..", "..", ".workflows", "solve-issue.yaml");
|
||||
describe("solve-issue workflow: Gitea API PR creation", () => {
|
||||
// Navigate up from packages/cli-workflow/src/__tests__ to repo root
|
||||
const workflowPath = join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
".workflows",
|
||||
"solve-issue.yaml",
|
||||
);
|
||||
|
||||
test("committer procedure should include --repo flag in tea pr create command", async () => {
|
||||
test("committer procedure should use curl API instead of tea pr create", async () => {
|
||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||
|
||||
@@ -24,43 +32,38 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
const committerProcedure = workflow.roles.committer?.procedure;
|
||||
expect(committerProcedure).toBeDefined();
|
||||
|
||||
// Verify the procedure includes tea pr create with --repo flag
|
||||
expect(committerProcedure).toContain("tea pr create");
|
||||
expect(committerProcedure).toContain("--repo");
|
||||
// Verify the procedure uses curl API, not tea pr create
|
||||
expect(committerProcedure).toContain("curl");
|
||||
expect(committerProcedure).toContain("api/v1/repos");
|
||||
expect(committerProcedure).toContain("/pulls");
|
||||
|
||||
// Verify the --repo flag appears before or together with tea pr create
|
||||
// This ensures the command is: tea pr create --repo <owner/repo> ...
|
||||
const teaPrCreateMatch = committerProcedure?.match(/tea pr create[^\n]*/);
|
||||
expect(teaPrCreateMatch).not.toBeNull();
|
||||
|
||||
if (teaPrCreateMatch) {
|
||||
const teaCommandLine = teaPrCreateMatch[0];
|
||||
expect(teaCommandLine).toContain("--repo");
|
||||
}
|
||||
// Verify it explicitly warns against tea pr create
|
||||
expect(committerProcedure).toMatch(/do NOT use.*tea pr create/i);
|
||||
});
|
||||
|
||||
test("committer procedure should mention repo extraction from git remote", async () => {
|
||||
test("committer procedure should reference repoRemote from task prompt", async () => {
|
||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||
|
||||
const committerProcedure = workflow.roles.committer?.procedure;
|
||||
expect(committerProcedure).toBeDefined();
|
||||
|
||||
// Verify the procedure mentions extracting repo info from git remote
|
||||
// This ensures fallback logic is documented
|
||||
expect(committerProcedure).toMatch(/git remote/i);
|
||||
// Verify the procedure mentions repoRemote is provided in task prompt
|
||||
expect(committerProcedure).toMatch(/repo remote.*provided.*task prompt/i);
|
||||
expect(committerProcedure).toMatch(/owner\/repo/i);
|
||||
});
|
||||
|
||||
test("committer procedure should include error handling for tea failures", async () => {
|
||||
test("committer procedure should include error handling for curl failures", async () => {
|
||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||
|
||||
const committerProcedure = workflow.roles.committer?.procedure;
|
||||
expect(committerProcedure).toBeDefined();
|
||||
|
||||
// Verify the procedure includes error handling guidance
|
||||
// Verify the procedure includes error handling guidance for curl
|
||||
// This ensures we capture failures and provide actionable output
|
||||
expect(committerProcedure).toMatch(/error|fail/i);
|
||||
expect(committerProcedure).toContain("hook_failed");
|
||||
});
|
||||
|
||||
test("workflow should be parseable as valid WorkflowPayload", async () => {
|
||||
@@ -81,17 +84,60 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
||||
});
|
||||
|
||||
test("committer frontmatter schema should require status field", async () => {
|
||||
test("committer frontmatter schema should be oneOf with $status discriminant", async () => {
|
||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const workflow = parse(yamlContent) as any;
|
||||
|
||||
const frontmatter = workflow.roles.committer?.frontmatter;
|
||||
expect(frontmatter).toBeDefined();
|
||||
expect(frontmatter?.type).toBe("object");
|
||||
expect(frontmatter?.properties?.status).toBeDefined();
|
||||
expect(frontmatter?.properties?.status?.enum).toContain("committed");
|
||||
expect(frontmatter?.required).toContain("status");
|
||||
expect(frontmatter?.oneOf).toBeDefined();
|
||||
const committedVariant = frontmatter.oneOf.find(
|
||||
(v: any) => v.properties?.$status?.const === "committed",
|
||||
);
|
||||
expect(committedVariant).toBeDefined();
|
||||
expect(committedVariant.required).toContain("$status");
|
||||
});
|
||||
|
||||
test("developer procedure should include mandatory verification step", async () => {
|
||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||
|
||||
const developerProcedure = workflow.roles.developer?.procedure;
|
||||
expect(developerProcedure).toBeDefined();
|
||||
|
||||
// Verify the procedure includes mandatory verification step
|
||||
expect(developerProcedure).toContain("MANDATORY VERIFICATION");
|
||||
expect(developerProcedure).toContain("git branch --show-current");
|
||||
expect(developerProcedure).toContain("git status");
|
||||
expect(developerProcedure).toMatch(/ls -la|verify.*exist/i);
|
||||
});
|
||||
|
||||
test("reviewer procedure should enforce worktree path verification", async () => {
|
||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||
|
||||
const reviewerProcedure = workflow.roles.reviewer?.procedure;
|
||||
expect(reviewerProcedure).toBeDefined();
|
||||
|
||||
// Verify the procedure includes critical enforcement
|
||||
expect(reviewerProcedure).toContain("CRITICAL");
|
||||
expect(reviewerProcedure).toMatch(/cd.*pwd/);
|
||||
expect(reviewerProcedure).toContain(
|
||||
"Do NOT report results without running the actual commands",
|
||||
);
|
||||
});
|
||||
|
||||
test("developer procedure should include test debugging escalation", async () => {
|
||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||
|
||||
const developerProcedure = workflow.roles.developer?.procedure;
|
||||
expect(developerProcedure).toBeDefined();
|
||||
|
||||
// Verify the procedure includes test failure guidance
|
||||
expect(developerProcedure).toMatch(/tests fail.*first run/i);
|
||||
expect(developerProcedure).toMatch(/3 test cycles|after 3 attempts/i);
|
||||
expect(developerProcedure).toContain("$status=failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
/**
|
||||
* B-group tests: validate JSON parsing logic used by spawnAgent.
|
||||
*
|
||||
* We test the parsing logic inline since spawnAgent is a private function.
|
||||
* These tests verify the contract: last line of stdout must be valid JSON
|
||||
* with a valid stepHash CasRef.
|
||||
*/
|
||||
|
||||
const CASREF_PATTERN = /^[0-9A-HJ-NP-TV-Z]{13}$/;
|
||||
|
||||
function isCasRef(s: string): boolean {
|
||||
return CASREF_PATTERN.test(s);
|
||||
}
|
||||
|
||||
type AdapterOutput = {
|
||||
stepHash: string;
|
||||
detailHash: string;
|
||||
role: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
startedAtMs: number;
|
||||
completedAtMs: number;
|
||||
};
|
||||
|
||||
function parseAgentStdout(stdout: string): AdapterOutput {
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
throw new Error(`agent stdout last line is not valid JSON: ${line || "(empty)"}`);
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (
|
||||
typeof obj !== "object" ||
|
||||
obj === null ||
|
||||
typeof obj.stepHash !== "string" ||
|
||||
!isCasRef(obj.stepHash as string)
|
||||
) {
|
||||
throw new Error(`agent stdout JSON missing valid stepHash: ${line}`);
|
||||
}
|
||||
return obj as unknown as AdapterOutput;
|
||||
}
|
||||
|
||||
const VALID_OUTPUT: AdapterOutput = {
|
||||
stepHash: "0123456789ABC",
|
||||
detailHash: "DEFGH12345678",
|
||||
role: "planner",
|
||||
frontmatter: { $status: "ready", plan: "somehash" },
|
||||
body: "Plan body",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
};
|
||||
|
||||
describe("spawnAgent JSON parsing", () => {
|
||||
test("B1. parses valid JSON from agent stdout", () => {
|
||||
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
|
||||
const result = parseAgentStdout(stdout);
|
||||
expect(result.stepHash).toBe("0123456789ABC");
|
||||
expect(result.detailHash).toBe("DEFGH12345678");
|
||||
expect(result.role).toBe("planner");
|
||||
expect(result.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
|
||||
expect(result.body).toBe("Plan body");
|
||||
expect(result.startedAtMs).toBe(1000);
|
||||
expect(result.completedAtMs).toBe(2000);
|
||||
});
|
||||
|
||||
test("B2. extracts stepHash for head pointer", () => {
|
||||
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
|
||||
const result = parseAgentStdout(stdout);
|
||||
expect(result.stepHash).toBe("0123456789ABC");
|
||||
expect(isCasRef(result.stepHash)).toBe(true);
|
||||
});
|
||||
|
||||
test("B3. handles debug lines before JSON", () => {
|
||||
const debugLines = "[debug] loading context...\n[debug] running agent...\n";
|
||||
const stdout = `${debugLines + JSON.stringify(VALID_OUTPUT)}\n`;
|
||||
const result = parseAgentStdout(stdout);
|
||||
expect(result.stepHash).toBe("0123456789ABC");
|
||||
});
|
||||
|
||||
test("B4. rejects non-JSON last line", () => {
|
||||
const stdout = "not-json-at-all\n";
|
||||
expect(() => parseAgentStdout(stdout)).toThrow("not valid JSON");
|
||||
});
|
||||
|
||||
test("B5. rejects JSON missing stepHash", () => {
|
||||
const incomplete = { detailHash: "DEFGH12345678", role: "planner" };
|
||||
const stdout = `${JSON.stringify(incomplete)}\n`;
|
||||
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
|
||||
});
|
||||
|
||||
test("B6. rejects JSON with invalid stepHash", () => {
|
||||
const bad = { ...VALID_OUTPUT, stepHash: "not-a-hash" };
|
||||
const stdout = `${JSON.stringify(bad)}\n`;
|
||||
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { bootstrap, putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdStepRead } from "../commands/step.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
|
||||
@@ -40,7 +40,7 @@ const DETAIL_SCHEMA = {
|
||||
turnCount: { type: "integer" as const },
|
||||
turns: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const, format: "cas_ref" },
|
||||
items: { type: "string" as const, format: "ocas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -66,13 +66,21 @@ function generateContent(size: number, prefix = "Content"): string {
|
||||
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-"));
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
// Restore original environment
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
// ── step read tests ───────────────────────────────────────────────────────────
|
||||
@@ -80,7 +88,10 @@ afterEach(async () => {
|
||||
describe("step read", () => {
|
||||
test("test 1: basic single-step read with 3 turns", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
await mkdir(casDir, { recursive: true });
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
@@ -144,10 +155,13 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
// Read step with large quota
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 10000, false);
|
||||
|
||||
// Assert structure
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
@@ -163,7 +177,9 @@ describe("step read", () => {
|
||||
|
||||
test("test 2: quota enforcement - multiple turns", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
await mkdir(casDir, { recursive: true });
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
@@ -227,10 +243,13 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
// Read step with limited quota (700 chars)
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 700);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 700, false);
|
||||
|
||||
// Assert only most recent turns fit
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
@@ -244,7 +263,9 @@ describe("step read", () => {
|
||||
|
||||
test("test 3: minimal quota edge case - always show at least one turn", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
await mkdir(casDir, { recursive: true });
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
@@ -304,10 +325,13 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
// Read step with minimal quota (1 char)
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 1);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 1, false);
|
||||
|
||||
// Assert at least one turn is always shown
|
||||
expect(markdown).toContain("LongTurn");
|
||||
@@ -316,7 +340,9 @@ describe("step read", () => {
|
||||
|
||||
test("test 4: step with no detail field", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
await mkdir(casDir, { recursive: true });
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
|
||||
@@ -357,10 +383,13 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
// Read step - should return metadata only (no error)
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
|
||||
|
||||
// Assert metadata is present
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
@@ -372,7 +401,9 @@ describe("step read", () => {
|
||||
|
||||
test("test 5: step with detail but no turns array", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
await mkdir(casDir, { recursive: true });
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
await registerDetailSchemas(store);
|
||||
@@ -431,10 +462,13 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
// Read step - should return metadata only (no error)
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
|
||||
|
||||
// Assert metadata is present
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
@@ -443,9 +477,85 @@ describe("step read", () => {
|
||||
expect(markdown).not.toContain("## Turn");
|
||||
});
|
||||
|
||||
test("test 6: turn content with special characters", async () => {
|
||||
test("test 6: displays role and tool calls in turn body", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
await mkdir(casDir, { recursive: true });
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [{ name: "terminal", args: '{"command":"echo hi"}' }],
|
||||
reasoning: null,
|
||||
});
|
||||
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "session-1",
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-hermes",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
|
||||
|
||||
expect(markdown).toContain("**Turn role:** assistant");
|
||||
expect(markdown).toContain("**terminal**");
|
||||
expect(markdown).toContain('{"command":"echo hi"}');
|
||||
});
|
||||
|
||||
test("test 7: turn content with special characters", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
await mkdir(casDir, { recursive: true });
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
@@ -505,10 +615,13 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
// Read step
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
|
||||
|
||||
// Assert content is rendered correctly without corruption
|
||||
expect(markdown).toContain("`backticks`");
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap, type Hash, type JSONSchema, putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef, StepNodePayload } from "@uncaged/workflow-protocol";
|
||||
import { cmdStepShow } from "../commands/step.js";
|
||||
import { formatOutput } from "../format.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
|
||||
const TURN_SCHEMA: JSONSchema = {
|
||||
title: "test-turn",
|
||||
type: "object",
|
||||
required: ["index", "role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" },
|
||||
role: { type: "string", enum: ["assistant", "tool"] },
|
||||
content: { type: "string" },
|
||||
toolCalls: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["name", "args"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
args: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const DETAIL_SCHEMA: JSONSchema = {
|
||||
title: "test-detail",
|
||||
type: "object",
|
||||
required: ["turns"],
|
||||
properties: {
|
||||
turns: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "ocas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
type TestSetup = {
|
||||
store: ReturnType<typeof createFsStore>;
|
||||
schemas: {
|
||||
workflow: Hash;
|
||||
startNode: Hash;
|
||||
stepNode: Hash;
|
||||
text: Hash;
|
||||
};
|
||||
turnType: Hash;
|
||||
detailType: Hash;
|
||||
};
|
||||
|
||||
async function setupTest(casDir: string): Promise<TestSetup> {
|
||||
const store = createFsStore(casDir);
|
||||
await bootstrap(store);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const [turnType, detailType] = await Promise.all([
|
||||
putSchema(store, TURN_SCHEMA),
|
||||
putSchema(store, DETAIL_SCHEMA),
|
||||
]);
|
||||
return { store, schemas, turnType, detailType };
|
||||
}
|
||||
|
||||
async function createTestStep(
|
||||
setup: TestSetup,
|
||||
turnPayloads: Array<{
|
||||
index: number;
|
||||
role: string;
|
||||
content: string;
|
||||
toolCalls: Array<{ name: string; args: string }> | null;
|
||||
}>,
|
||||
): Promise<CasRef> {
|
||||
const { store, schemas, turnType, detailType } = setup;
|
||||
|
||||
// Create turn nodes
|
||||
const turnHashes: CasRef[] = [];
|
||||
for (const payload of turnPayloads) {
|
||||
const turnHash = await store.put(turnType, payload);
|
||||
turnHashes.push(turnHash);
|
||||
}
|
||||
|
||||
// Create detail node
|
||||
const detailHash = await store.put(detailType, { turns: turnHashes });
|
||||
|
||||
// Create dummy start node
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: "0000000000000" as CasRef,
|
||||
prompt: "test prompt",
|
||||
cwd: "/tmp",
|
||||
});
|
||||
|
||||
// Create dummy output node
|
||||
const outputHash = await store.put(schemas.text, { $status: "done" });
|
||||
|
||||
// Create step node
|
||||
const stepPayload: StepNodePayload = {
|
||||
prev: null,
|
||||
start: startHash,
|
||||
role: "test-role",
|
||||
agent: "test-agent",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
edgePrompt: "",
|
||||
startedAtMs: Date.now(),
|
||||
completedAtMs: Date.now() + 1000,
|
||||
assembledPrompt: null,
|
||||
cwd: "/tmp",
|
||||
};
|
||||
return store.put(schemas.stepNode, stepPayload);
|
||||
}
|
||||
|
||||
describe("cmdStepShow JSON serialization", () => {
|
||||
let testDir: string;
|
||||
let casDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), "uwf-test-"));
|
||||
casDir = join(testDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("escapes newlines in tool call args", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Running command",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "echo 'line1'\necho 'line2'",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
expect(jsonOutput).toContain("\\n");
|
||||
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns[0].toolCalls[0].args).toContain("\n");
|
||||
});
|
||||
|
||||
test("escapes tabs in tool call args", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "cat <<EOF\nfield1\tfield2\tfield3\nEOF",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
expect(jsonOutput).toContain("\\t");
|
||||
});
|
||||
|
||||
test("escapes carriage returns", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Committing changes",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: 'git commit -m "First line\r\nSecond line"',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
expect(jsonOutput).toContain("\\r\\n");
|
||||
});
|
||||
|
||||
test("escapes backslashes and quotes", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: 'echo "He said \\"hello\\""',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles Unicode control characters", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "echo '\u0001\u001F'",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
});
|
||||
|
||||
test("handles nested CAS refs with control characters", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "First turn\nwith newline",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "cmd1\nline2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
role: "assistant",
|
||||
content: "Second turn\twith tab",
|
||||
toolCalls: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("YAML output format is unaffected", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Running command",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "echo 'line1'\necho 'line2'",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const yamlOutput = formatOutput(result, "yaml");
|
||||
|
||||
expect(yamlOutput).toContain("turns:");
|
||||
expect(yamlOutput.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("handles empty and null values", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles large step with multiple tool calls", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
|
||||
const turns = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
turns.push({
|
||||
index: i,
|
||||
role: "assistant" as const,
|
||||
content: `Turn ${i}\nwith newline`,
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: `command${i}\nline2\tfield${i}`,
|
||||
},
|
||||
{
|
||||
name: "Read",
|
||||
args: `/path/to/file${i}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const stepHash = await createTestStep(setup, turns);
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(duration).toBeLessThan(2000);
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns).toHaveLength(25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap, putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol";
|
||||
import { cmdStepList } from "../commands/step.js";
|
||||
import { cmdThreadRead } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import { saveThreadsIndex } from "../store.js";
|
||||
|
||||
// ── schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const TURN_SCHEMA = {
|
||||
title: "hermes-turn",
|
||||
type: "object" as const,
|
||||
required: ["index", "role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" as const },
|
||||
role: { type: "string" as const },
|
||||
content: { type: "string" as const },
|
||||
toolCalls: {
|
||||
anyOf: [
|
||||
{ type: "array" as const, items: { type: "object" as const } },
|
||||
{ type: "null" as const },
|
||||
],
|
||||
},
|
||||
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const DETAIL_SCHEMA = {
|
||||
title: "hermes-detail",
|
||||
type: "object" as const,
|
||||
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||
properties: {
|
||||
sessionId: { type: "string" as const },
|
||||
model: { type: "string" as const },
|
||||
duration: { type: "integer" as const },
|
||||
turnCount: { type: "integer" as const },
|
||||
turns: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const, format: "ocas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||
await bootstrap(store);
|
||||
const [turn, detail] = await Promise.all([
|
||||
putSchema(store, TURN_SCHEMA),
|
||||
putSchema(store, DETAIL_SCHEMA),
|
||||
]);
|
||||
return { turn, detail };
|
||||
}
|
||||
|
||||
// ── fixture ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-"));
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = join(tmpDir, "cas");
|
||||
await mkdir(process.env.UNCAGED_CAS_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
// ── 1. Protocol types (compile-time) ─────────────────────────────────────────
|
||||
|
||||
describe("protocol types", () => {
|
||||
test("StepRecord has startedAtMs and completedAtMs as required fields", () => {
|
||||
// Type-level test: this block compiles only if fields exist and are number
|
||||
const record: import("@uncaged/workflow-protocol").StepRecord = {
|
||||
role: "test",
|
||||
output: "hash1" as CasRef,
|
||||
detail: "hash2" as CasRef,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
assembledPrompt: null,
|
||||
cwd: "/test/path",
|
||||
};
|
||||
expect(record.startedAtMs).toBe(1000);
|
||||
expect(record.completedAtMs).toBe(2000);
|
||||
});
|
||||
|
||||
test("StepEntry has durationMs as required field", () => {
|
||||
const entry: import("@uncaged/workflow-protocol").StepEntry = {
|
||||
hash: "hash" as CasRef,
|
||||
role: "test",
|
||||
output: {},
|
||||
detail: "hash2" as CasRef,
|
||||
agent: "uwf-test",
|
||||
timestamp: 123,
|
||||
durationMs: 5000,
|
||||
};
|
||||
expect(entry.durationMs).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. JSON Schema ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("StepNode JSON schema", () => {
|
||||
test("schema requires startedAtMs and completedAtMs", () => {
|
||||
const required = STEP_NODE_SCHEMA.required as string[];
|
||||
expect(required).toContain("startedAtMs");
|
||||
expect(required).toContain("completedAtMs");
|
||||
});
|
||||
|
||||
test("schema defines timing fields as integer", () => {
|
||||
const props = STEP_NODE_SCHEMA.properties as Record<string, { type: string }>;
|
||||
expect(props.startedAtMs.type).toBe("integer");
|
||||
expect(props.completedAtMs.type).toBe("integer");
|
||||
});
|
||||
|
||||
test("StepNode with timing fields passes CAS validation", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: "placeholder0000" as CasRef,
|
||||
prompt: "test",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.text, "output text");
|
||||
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "s1",
|
||||
model: "m1",
|
||||
duration: 100,
|
||||
turnCount: 0,
|
||||
turns: [],
|
||||
});
|
||||
|
||||
// Should succeed — valid timing fields
|
||||
const hash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. step list — durationMs computed ───────────────────────────────────────
|
||||
|
||||
describe("step list timing", () => {
|
||||
test("step list includes durationMs = completedAtMs - startedAtMs", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "test",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.text, "output");
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "s1",
|
||||
model: "m1",
|
||||
duration: 100,
|
||||
turnCount: 0,
|
||||
turns: [],
|
||||
});
|
||||
|
||||
const startedAt = 1716600000000;
|
||||
const completedAt = 1716600003500;
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: startedAt,
|
||||
completedAtMs: completedAt,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const result = await cmdStepList(tmpDir, threadId);
|
||||
const stepEntries = result.steps.slice(1); // skip start entry
|
||||
expect(stepEntries).toHaveLength(1);
|
||||
|
||||
const step = stepEntries[0] as import("@uncaged/workflow-protocol").StepEntry;
|
||||
expect(step.durationMs).toBe(3500);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. thread read — duration in header ──────────────────────────────────────
|
||||
|
||||
describe("thread read timing", () => {
|
||||
test("thread read header includes Duration", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "Do work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: "placeholder0000" as CasRef,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "go", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "test task",
|
||||
});
|
||||
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Done.",
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "s1",
|
||||
model: "m1",
|
||||
duration: 100,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
const outputHash = await store.put(schemas.text, "output");
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1716600000000,
|
||||
completedAtMs: 1716600042000,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ3" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
|
||||
expect(markdown).toContain("**Duration:** 42.0s");
|
||||
});
|
||||
|
||||
test("thread read shows sub-second duration as ms", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "Do work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: "placeholder0000" as CasRef,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "go", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "test",
|
||||
});
|
||||
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Done.",
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "s1",
|
||||
model: "m1",
|
||||
duration: 100,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
const outputHash = await store.put(schemas.text, "output");
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1716600000000,
|
||||
completedAtMs: 1716600000350,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
|
||||
expect(markdown).toContain("**Duration:** 350ms");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 6. Breaking change — old data without timing fails ───────────────────────
|
||||
|
||||
describe("breaking change", () => {
|
||||
test("StepNode schema rejects payload without timing fields", () => {
|
||||
const required = STEP_NODE_SCHEMA.required as string[];
|
||||
// Both fields must be in the required array
|
||||
expect(required).toContain("startedAtMs");
|
||||
expect(required).toContain("completedAtMs");
|
||||
|
||||
// Payload without timing fields would fail schema validation
|
||||
// because the schema marks them as required
|
||||
const payloadWithoutTiming = {
|
||||
start: "hash1",
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: "hash2",
|
||||
detail: "hash3",
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
};
|
||||
// Verify the payload is missing required fields
|
||||
expect(payloadWithoutTiming).not.toHaveProperty("startedAtMs");
|
||||
expect(payloadWithoutTiming).not.toHaveProperty("completedAtMs");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createUwfStore, getCasDir, getGlobalCasDir } from "../store.js";
|
||||
|
||||
describe("Global CAS directory", () => {
|
||||
let tmpDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = join(tmpdir(), `uwf-test-global-cas-${Date.now()}`);
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("getGlobalCasDir returns default path when no env var set", () => {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
const casDir = getGlobalCasDir();
|
||||
// Should return ~/.uncaged/json-cas
|
||||
expect(casDir).toContain(".uncaged");
|
||||
expect(casDir).toContain("json-cas");
|
||||
});
|
||||
|
||||
test("getGlobalCasDir respects UNCAGED_CAS_DIR environment variable", () => {
|
||||
const customPath = join(tmpDir, "custom-cas");
|
||||
process.env.UNCAGED_CAS_DIR = customPath;
|
||||
const casDir = getGlobalCasDir();
|
||||
expect(casDir).toBe(customPath);
|
||||
});
|
||||
|
||||
test("getGlobalCasDir ignores empty UNCAGED_CAS_DIR", () => {
|
||||
process.env.UNCAGED_CAS_DIR = "";
|
||||
const casDir = getGlobalCasDir();
|
||||
expect(casDir).toContain(".uncaged");
|
||||
expect(casDir).toContain("json-cas");
|
||||
});
|
||||
|
||||
test("getCasDir is deprecated but still works for backward compatibility", () => {
|
||||
const storageRoot = join(tmpDir, "storage");
|
||||
const casDir = getCasDir(storageRoot);
|
||||
expect(casDir).toBe(join(storageRoot, "cas"));
|
||||
});
|
||||
|
||||
test("createUwfStore uses global CAS directory", async () => {
|
||||
const globalCasDir = join(tmpDir, "global-cas");
|
||||
process.env.UNCAGED_CAS_DIR = globalCasDir;
|
||||
|
||||
const storageRoot = join(tmpDir, "storage");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
|
||||
// Verify the store was created in the global CAS directory
|
||||
expect(uwf.storageRoot).toBe(storageRoot);
|
||||
expect(uwf.store).toBeDefined();
|
||||
expect(uwf.schemas).toBeDefined();
|
||||
|
||||
// The global CAS directory should be created
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const stats = await stat(globalCasDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
test("createUwfStore creates global CAS directory if it does not exist", async () => {
|
||||
const globalCasDir = join(tmpDir, "new-global-cas");
|
||||
process.env.UNCAGED_CAS_DIR = globalCasDir;
|
||||
|
||||
const storageRoot = join(tmpDir, "storage");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
|
||||
await createUwfStore(storageRoot);
|
||||
|
||||
// Verify the directory was created
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const stats = await stat(globalCasDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
test("multiple uwfStore instances share the same global CAS filesystem", async () => {
|
||||
const globalCasDir = join(tmpDir, "shared-cas");
|
||||
process.env.UNCAGED_CAS_DIR = globalCasDir;
|
||||
|
||||
const storageRoot1 = join(tmpDir, "storage1");
|
||||
const storageRoot2 = join(tmpDir, "storage2");
|
||||
await mkdir(storageRoot1, { recursive: true });
|
||||
await mkdir(storageRoot2, { recursive: true });
|
||||
|
||||
const uwf1 = await createUwfStore(storageRoot1);
|
||||
const uwf2 = await createUwfStore(storageRoot2);
|
||||
|
||||
// Both should use the same global CAS directory
|
||||
expect(uwf1.store).toBeDefined();
|
||||
expect(uwf2.store).toBeDefined();
|
||||
|
||||
// Store a node in the first store
|
||||
const testData = { test: "data" };
|
||||
const _hash = uwf1.store.put(uwf1.schemas.text, JSON.stringify(testData));
|
||||
|
||||
// Both stores share the same CAS filesystem directory
|
||||
// Since schemas are registered idempotently, they should have the same hash
|
||||
expect(uwf2.schemas.text).toBe(uwf1.schemas.text);
|
||||
|
||||
// Verify the CAS files are written to the shared directory
|
||||
const { readdir } = await import("node:fs/promises");
|
||||
const files = await readdir(globalCasDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("workflow metadata remains in storageRoot, not global CAS", async () => {
|
||||
const globalCasDir = join(tmpDir, "global-cas");
|
||||
process.env.UNCAGED_CAS_DIR = globalCasDir;
|
||||
|
||||
const storageRoot = join(tmpDir, "storage");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
|
||||
const _uwf = await createUwfStore(storageRoot);
|
||||
|
||||
// Write workflow registry file
|
||||
const { saveWorkflowRegistry } = await import("../store.js");
|
||||
await saveWorkflowRegistry(storageRoot, { "test-workflow": "ABC123" });
|
||||
|
||||
// Verify registry is in storageRoot, not global CAS
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const registryPath = join(storageRoot, "workflows.yaml");
|
||||
const content = await readFile(registryPath, "utf8");
|
||||
expect(content).toContain("test-workflow");
|
||||
expect(content).toContain("ABC123");
|
||||
|
||||
// Verify registry is NOT in global CAS directory
|
||||
const globalRegistryPath = join(globalCasDir, "workflows.yaml");
|
||||
await expect(readFile(globalRegistryPath, "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("thread metadata remains in storageRoot", async () => {
|
||||
const globalCasDir = join(tmpDir, "global-cas");
|
||||
process.env.UNCAGED_CAS_DIR = globalCasDir;
|
||||
|
||||
const storageRoot = join(tmpDir, "storage");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
|
||||
await createUwfStore(storageRoot);
|
||||
|
||||
// Write threads index
|
||||
const { saveThreadsIndex } = await import("../store.js");
|
||||
await saveThreadsIndex(storageRoot, { "thread-123": "hash-456" });
|
||||
|
||||
// Verify threads.yaml is in storageRoot, not global CAS
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const threadsPath = join(storageRoot, "threads.yaml");
|
||||
const content = await readFile(threadsPath, "utf8");
|
||||
expect(content).toContain("thread-123");
|
||||
expect(content).toContain("hash-456");
|
||||
|
||||
// Verify threads.yaml is NOT in global CAS directory
|
||||
const globalThreadsPath = join(globalCasDir, "threads.yaml");
|
||||
await expect(readFile(globalThreadsPath, "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("history remains in storageRoot", async () => {
|
||||
const globalCasDir = join(tmpDir, "global-cas");
|
||||
process.env.UNCAGED_CAS_DIR = globalCasDir;
|
||||
|
||||
const storageRoot = join(tmpDir, "storage");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
|
||||
await createUwfStore(storageRoot);
|
||||
|
||||
// Write history
|
||||
const { appendThreadHistory } = await import("../store.js");
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: "thread-123" as any,
|
||||
workflow: "workflow-456",
|
||||
head: "hash-789",
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
// Verify history.jsonl is in storageRoot, not global CAS
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const historyPath = join(storageRoot, "history.jsonl");
|
||||
const content = await readFile(historyPath, "utf8");
|
||||
expect(content).toContain("thread-123");
|
||||
expect(content).toContain("workflow-456");
|
||||
|
||||
// Verify history.jsonl is NOT in global CAS directory
|
||||
const globalHistoryPath = join(globalCasDir, "history.jsonl");
|
||||
await expect(readFile(globalHistoryPath, "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("CAS nodes are stored in global directory", async () => {
|
||||
const globalCasDir = join(tmpDir, "global-cas");
|
||||
process.env.UNCAGED_CAS_DIR = globalCasDir;
|
||||
|
||||
const storageRoot = join(tmpDir, "storage");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
|
||||
// Store a CAS node
|
||||
const testPayload = JSON.stringify({ test: "node" });
|
||||
const _hash = uwf.store.put(uwf.schemas.text, testPayload);
|
||||
|
||||
// Verify the node is in global CAS directory
|
||||
const { readdir } = await import("node:fs/promises");
|
||||
const files = await readdir(globalCasDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify the node is NOT in the old storageRoot/cas location
|
||||
const oldCasDir = join(storageRoot, "cas");
|
||||
await expect(readdir(oldCasDir)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { appendThreadHistory, loadThreadHistory } from "../store.js";
|
||||
|
||||
describe("thread cancel status", () => {
|
||||
test("cancelled history entry has reason 'cancelled'", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
|
||||
const threadId = "01JTEST000000000000CANCEL1" as ThreadId;
|
||||
|
||||
await appendThreadHistory(tmpDir, {
|
||||
thread: threadId,
|
||||
workflow: "test-workflow",
|
||||
head: "test-head-hash" as CasRef,
|
||||
completedAt: Date.now(),
|
||||
reason: "cancelled",
|
||||
});
|
||||
|
||||
const history = await loadThreadHistory(tmpDir);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]?.reason).toBe("cancelled");
|
||||
});
|
||||
|
||||
test("completed history entry has reason 'completed'", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
|
||||
const threadId = "01JTEST000000000000CANCEL2" as ThreadId;
|
||||
|
||||
await appendThreadHistory(tmpDir, {
|
||||
thread: threadId,
|
||||
workflow: "test-workflow",
|
||||
head: "test-head-hash" as CasRef,
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
const history = await loadThreadHistory(tmpDir);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]?.reason).toBe("completed");
|
||||
});
|
||||
|
||||
test("legacy history entry without reason parses as null", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
|
||||
const threadId = "01JTEST000000000000CANCEL3" as ThreadId;
|
||||
|
||||
// Simulate legacy entry without reason field
|
||||
await appendThreadHistory(tmpDir, {
|
||||
thread: threadId,
|
||||
workflow: "test-workflow",
|
||||
head: "test-head-hash" as CasRef,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const history = await loadThreadHistory(tmpDir);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]?.reason).toBeNull();
|
||||
});
|
||||
|
||||
test("mixed completed and cancelled entries preserve distinct reasons", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
|
||||
|
||||
await appendThreadHistory(tmpDir, {
|
||||
thread: "01JTEST000000000000CANCEL4" as ThreadId,
|
||||
workflow: "test-workflow",
|
||||
head: "head1" as CasRef,
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
await appendThreadHistory(tmpDir, {
|
||||
thread: "01JTEST000000000000CANCEL5" as ThreadId,
|
||||
workflow: "test-workflow",
|
||||
head: "head2" as CasRef,
|
||||
completedAt: Date.now(),
|
||||
reason: "cancelled",
|
||||
});
|
||||
|
||||
const history = await loadThreadHistory(tmpDir);
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0]?.reason).toBe("completed");
|
||||
expect(history[1]?.reason).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { createThreadIndexEntry } from "@uncaged/workflow-protocol";
|
||||
import { extractUlidTimestamp, generateUlid } from "@uncaged/workflow-util";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { createMarker, deleteMarker } from "../background/index.js";
|
||||
import { cmdThreadList } from "../commands/thread.js";
|
||||
import { parseTimeInput } from "../commands/thread-time-parser.js";
|
||||
@@ -15,6 +16,8 @@ import { appendThreadHistory, createUwfStore, saveThreadsIndex } from "../store.
|
||||
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
// Set UNCAGED_CAS_DIR to use the test's CAS directory
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
return createUwfStore(storageRoot);
|
||||
}
|
||||
|
||||
@@ -43,11 +46,15 @@ async function createTestThread(
|
||||
const startPayload = {
|
||||
workflow: workflowHash,
|
||||
prompt: "test prompt",
|
||||
cwd: storageRoot,
|
||||
};
|
||||
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||
index[threadId] = headHash;
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
// Load existing index and add new thread
|
||||
const existingIndex = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||
existingIndex[threadId] = createThreadIndexEntry(headHash);
|
||||
await saveThreadsIndex(storageRoot, existingIndex);
|
||||
|
||||
return threadId;
|
||||
}
|
||||
|
||||
@@ -74,6 +81,7 @@ async function completeThread(
|
||||
workflow: workflowHash,
|
||||
head: headHash,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,7 +111,7 @@ describe("cmdThreadList status filter", () => {
|
||||
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||
const thread3Head = index[thread3];
|
||||
const thread3Head = index[thread3]!.head;
|
||||
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||
|
||||
@@ -127,7 +135,7 @@ describe("cmdThreadList status filter", () => {
|
||||
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||
const thread3Head = index[thread3];
|
||||
const thread3Head = index[thread3]!.head;
|
||||
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||
|
||||
@@ -151,7 +159,7 @@ describe("cmdThreadList status filter", () => {
|
||||
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||
const thread3Head = index[thread3];
|
||||
const thread3Head = index[thread3]!.head;
|
||||
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||
|
||||
@@ -173,7 +181,7 @@ describe("cmdThreadList status filter", () => {
|
||||
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||
const thread3Head = index[thread3];
|
||||
const thread3Head = index[thread3]!.head;
|
||||
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||
|
||||
@@ -345,7 +353,7 @@ describe("combined filters", () => {
|
||||
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||
const thread3Head = index[thread3];
|
||||
const thread3Head = index[thread3]!.head;
|
||||
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||
|
||||
@@ -369,7 +377,7 @@ describe("combined filters", () => {
|
||||
const thread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 1000);
|
||||
threads.push(thread);
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||
const headHash = index[thread];
|
||||
const headHash = index[thread]!.head;
|
||||
if (headHash === undefined) throw new Error("head not found");
|
||||
await completeThread(tmpDir, thread, workflowHash, headHash);
|
||||
}
|
||||
@@ -418,7 +426,7 @@ describe("combined filters", () => {
|
||||
|
||||
if (i % 2 === 0) {
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||
const headHash = index[thread];
|
||||
const headHash = index[thread]!.head;
|
||||
if (headHash === undefined) throw new Error("head not found");
|
||||
await completeThread(tmpDir, thread, workflowHash, headHash);
|
||||
} else {
|
||||
@@ -476,7 +484,11 @@ describe("edge cases", () => {
|
||||
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||
index["INVALID_ULID_FORMAT_HERE" as ThreadId] = "01J6HMVRNQKJV2";
|
||||
index["INVALID_ULID_FORMAT_HERE" as ThreadId] = {
|
||||
head: "01J6HMVRNQKJV2",
|
||||
suspendedRole: null,
|
||||
suspendMessage: null,
|
||||
};
|
||||
await saveThreadsIndex(tmpDir, index);
|
||||
|
||||
const afterMs = Date.now() - 3000;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { cmdThreadStart } from "../commands/thread.js";
|
||||
import { createUwfStore } from "../store.js";
|
||||
|
||||
describe("Thread and edge location integration", () => {
|
||||
let tmpDir: string;
|
||||
let storageRoot: string;
|
||||
let casDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
async function setupTestEnv() {
|
||||
tmpDir = join(tmpdir(), `uwf-test-location-${Date.now()}`);
|
||||
storageRoot = join(tmpDir, "storage");
|
||||
casDir = join(tmpDir, "cas");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
await mkdir(casDir, { recursive: true });
|
||||
|
||||
// Set UNCAGED_CAS_DIR for this test
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (tmpDir) {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
// Restore original environment
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
}
|
||||
}
|
||||
|
||||
test("thread start captures cwd in StartNode", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowYaml = `
|
||||
name: test-location
|
||||
description: Test workflow for location feature
|
||||
roles:
|
||||
planner:
|
||||
description: Plans the work
|
||||
goal: Plan implementation
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan the work"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-location.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
|
||||
const testCwd = "/test/project/path";
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
|
||||
|
||||
expect(result.thread).toBeDefined();
|
||||
expect(result.workflow).toBeDefined();
|
||||
|
||||
// Verify StartNode has the cwd field
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||
const headHash = index[result.thread as ThreadId]!.head;
|
||||
expect(headHash).toBeDefined();
|
||||
|
||||
const startNode = uwf.store.get(headHash as CasRef);
|
||||
expect(startNode).not.toBe(null);
|
||||
expect(startNode?.type).toBe(uwf.schemas.startNode);
|
||||
|
||||
const startPayload = startNode?.payload as StartNodePayload;
|
||||
expect(startPayload.cwd).toBe(testCwd);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start validates cwd is absolute path", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowYaml = `
|
||||
name: test-location
|
||||
description: Test workflow
|
||||
roles:
|
||||
planner:
|
||||
description: Plans
|
||||
goal: Plan
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-location.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
|
||||
// Relative path should fail via fail() → process.exit (mocked in test preload)
|
||||
await expect(
|
||||
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
|
||||
).rejects.toThrow();
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start uses process.cwd() as default", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowYaml = `
|
||||
name: test-default-cwd
|
||||
description: Test default cwd
|
||||
roles:
|
||||
planner:
|
||||
description: Plans
|
||||
goal: Plan
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-default-cwd.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test", tmpDir);
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||
const headHash = index[result.thread as ThreadId]!.head;
|
||||
|
||||
const startNode = uwf.store.get(headHash as CasRef);
|
||||
const startPayload = startNode?.payload as StartNodePayload;
|
||||
|
||||
// Should default to process.cwd()
|
||||
expect(startPayload.cwd).toBe(process.cwd());
|
||||
|
||||
await teardown();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { bootstrap, putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdThreadRead } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import { saveThreadsIndex } from "../store.js";
|
||||
@@ -41,7 +41,7 @@ const DETAIL_SCHEMA = {
|
||||
turnCount: { type: "integer" as const },
|
||||
turns: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const, format: "cas_ref" },
|
||||
items: { type: "string" as const, format: "ocas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -67,13 +67,22 @@ function generateContent(size: number, prefix = "Content"): string {
|
||||
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-"));
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = join(tmpDir, "cas");
|
||||
await mkdir(process.env.UNCAGED_CAS_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
// ── thread read quota enforcement ─────────────────────────────────────────────
|
||||
@@ -141,6 +150,9 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
@@ -221,6 +233,9 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: step1DetailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const step2Content = generateContent(600, "Second");
|
||||
@@ -245,6 +260,9 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: step2DetailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||
@@ -328,6 +346,9 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
@@ -338,8 +359,8 @@ describe("thread read --quota flag", () => {
|
||||
// Set tight quota with --start flag
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
|
||||
|
||||
// Quota must be reasonably enforced (allow ~210 char tolerance for structure)
|
||||
expect(markdown.length).toBeLessThanOrEqual(810);
|
||||
// Quota must be reasonably enforced (allow ~260 char tolerance for structure)
|
||||
expect(markdown.length).toBeLessThanOrEqual(860);
|
||||
|
||||
// Should contain thread header
|
||||
expect(markdown).toMatch(/# Thread/);
|
||||
@@ -405,6 +426,9 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||
@@ -480,6 +504,9 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
@@ -559,6 +586,9 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { bootstrap, putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdThreadRead, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import type { UwfStore } from "../store.js";
|
||||
@@ -42,7 +42,7 @@ const DETAIL_SCHEMA = {
|
||||
turnCount: { type: "integer" as const },
|
||||
turns: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const, format: "cas_ref" },
|
||||
items: { type: "string" as const, format: "ocas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -53,6 +53,8 @@ const DETAIL_SCHEMA = {
|
||||
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
// Set UNCAGED_CAS_DIR to use the test's CAS directory
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
return { storageRoot, store, schemas };
|
||||
@@ -139,6 +141,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-claude-code",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000001" as ThreadId;
|
||||
@@ -214,6 +219,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-claude-code",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000002" as ThreadId;
|
||||
@@ -274,6 +282,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
@@ -283,6 +294,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||
@@ -335,6 +349,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||
@@ -387,6 +404,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: missingDetailRef,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||
@@ -439,6 +459,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000006" as ThreadId;
|
||||
@@ -511,6 +534,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
@@ -520,6 +546,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
@@ -529,6 +558,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000007" as ThreadId;
|
||||
@@ -607,6 +639,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000008" as ThreadId;
|
||||
@@ -661,6 +696,9 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
})) as CasRef;
|
||||
steps.push(step);
|
||||
prev = step;
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
import { cmdThreadShow } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import { saveThreadsIndex } from "../store.js";
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$status: { type: "string" as const },
|
||||
question: { type: "string" as const },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const THREAD_ID = "01RESUMESTEPTEST0000000" as ThreadId;
|
||||
const SUSPEND_MESSAGE = "Please clarify: Which API?";
|
||||
|
||||
type MockAgentMode = "suspend" | "ok";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-resume-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function setupSuspendedThread(mode: MockAgentMode): Promise<{
|
||||
casDir: string;
|
||||
mockAgentPath: string;
|
||||
promptCapturePath: string;
|
||||
}> {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-resume",
|
||||
description: "resume command integration test",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker role",
|
||||
goal: "Work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: outputSchemaHash,
|
||||
},
|
||||
reviewer: {
|
||||
description: "Reviewer role",
|
||||
goal: "Review",
|
||||
capabilities: [],
|
||||
procedure: "review",
|
||||
output: "result",
|
||||
frontmatter: outputSchemaHash,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "Start work", location: null } },
|
||||
worker: {
|
||||
needs_input: {
|
||||
role: "$SUSPEND",
|
||||
prompt: "Please clarify: {{{question}}}",
|
||||
location: null,
|
||||
},
|
||||
ok: { role: "reviewer", prompt: "Review the work", location: null },
|
||||
},
|
||||
reviewer: { _: { role: "$END", prompt: "Done", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test resume task",
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
await saveThreadsIndex(tmpDir, { [THREAD_ID]: startHash });
|
||||
|
||||
const outputHash = await store.put(outputSchemaHash, {
|
||||
$status: "needs_input",
|
||||
question: "Which API?",
|
||||
});
|
||||
const detailHash = await store.put(schemas.text, "mock detail");
|
||||
|
||||
const startedAtMs = 1716600000000;
|
||||
const completedAtMs = 1716600001500;
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-mock",
|
||||
edgePrompt: "Start work",
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
cwd: tmpDir,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
await saveThreadsIndex(tmpDir, {
|
||||
[THREAD_ID]: {
|
||||
head: stepHash,
|
||||
suspendedRole: "worker",
|
||||
suspendMessage: SUSPEND_MESSAGE,
|
||||
},
|
||||
});
|
||||
|
||||
const promptCapturePath = join(tmpDir, "captured-prompt.txt");
|
||||
const mockAgentPath = join(tmpDir, "mock-agent.sh");
|
||||
|
||||
const frontmatter =
|
||||
mode === "suspend" ? { $status: "needs_input", question: "Which API?" } : { $status: "ok" };
|
||||
|
||||
const adapterJson = JSON.stringify({
|
||||
stepHash: await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: stepHash,
|
||||
role: "worker",
|
||||
output: await store.put(outputSchemaHash, frontmatter),
|
||||
detail: detailHash,
|
||||
agent: "uwf-mock",
|
||||
edgePrompt: "resume prompt placeholder",
|
||||
startedAtMs: completedAtMs + 1,
|
||||
completedAtMs: completedAtMs + 2,
|
||||
cwd: tmpDir,
|
||||
assembledPrompt: null,
|
||||
}),
|
||||
detailHash,
|
||||
role: "worker",
|
||||
frontmatter,
|
||||
body: "",
|
||||
startedAtMs: completedAtMs + 1,
|
||||
completedAtMs: completedAtMs + 2,
|
||||
});
|
||||
|
||||
await writeFile(
|
||||
mockAgentPath,
|
||||
`#!/bin/sh
|
||||
prompt=""
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--prompt" ]; then
|
||||
prompt="$2"
|
||||
shift 2
|
||||
else
|
||||
shift
|
||||
fi
|
||||
done
|
||||
printf '%s' "$prompt" > '${promptCapturePath}'
|
||||
echo '${adapterJson}'
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const configPath = join(tmpDir, "config.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
|
||||
);
|
||||
|
||||
return { casDir, mockAgentPath, promptCapturePath };
|
||||
}
|
||||
|
||||
function runUwf(
|
||||
args: string[],
|
||||
casDir: string,
|
||||
): { stdout: string; stderr: string; status: number } {
|
||||
const cliPath = join(import.meta.dirname, "..", "cli.js");
|
||||
try {
|
||||
const stdout = execFileSync("bun", ["run", cliPath, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
WORKFLOW_STORAGE_ROOT: tmpDir,
|
||||
UNCAGED_CAS_DIR: casDir,
|
||||
},
|
||||
cwd: tmpDir,
|
||||
timeout: 30000,
|
||||
});
|
||||
return { stdout, stderr: "", status: 0 };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & {
|
||||
stdout?: string | Buffer;
|
||||
stderr?: string | Buffer;
|
||||
status?: number;
|
||||
};
|
||||
return {
|
||||
stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString("utf8") ?? ""),
|
||||
stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString("utf8") ?? ""),
|
||||
status: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("uwf thread resume", () => {
|
||||
test("resume non-suspended thread returns error", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "idle-workflow",
|
||||
description: "idle thread",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "Work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: await putSchema(store, OUTPUT_SCHEMA),
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "Start", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "Done", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "task",
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
await saveThreadsIndex(tmpDir, { [THREAD_ID]: startHash });
|
||||
|
||||
const result = runUwf(["thread", "resume", THREAD_ID], casDir);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("thread is not suspended");
|
||||
});
|
||||
|
||||
test("resume suspended thread executes step and becomes idle", async () => {
|
||||
const originalCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
const { casDir, mockAgentPath } = await setupSuspendedThread("ok");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
try {
|
||||
const result = runUwf(["thread", "resume", THREAD_ID, "--agent", mockAgentPath], casDir);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const cliOutput = JSON.parse(result.stdout.trim());
|
||||
expect(cliOutput.status).toBe("idle");
|
||||
expect(cliOutput.currentRole).toBe("reviewer");
|
||||
expect(cliOutput.suspendedRole).toBeNull();
|
||||
expect(cliOutput.suspendMessage).toBeNull();
|
||||
expect(cliOutput.done).toBe(false);
|
||||
|
||||
const threadsYaml = await readFile(join(tmpDir, "threads.yaml"), "utf8");
|
||||
const threadsIndex = parse(threadsYaml) as Record<string, unknown>;
|
||||
expect(threadsIndex[THREAD_ID]).toBe(cliOutput.head);
|
||||
|
||||
const showResult = await cmdThreadShow(tmpDir, THREAD_ID);
|
||||
expect(showResult.status).toBe("idle");
|
||||
expect(showResult.suspendedRole).toBeNull();
|
||||
expect(showResult.suspendMessage).toBeNull();
|
||||
} finally {
|
||||
if (originalCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalCasDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("resume without -p uses suspend message as agent prompt", async () => {
|
||||
const originalCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
const { casDir, mockAgentPath, promptCapturePath } = await setupSuspendedThread("ok");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
try {
|
||||
const result = runUwf(["thread", "resume", THREAD_ID, "--agent", mockAgentPath], casDir);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const capturedPrompt = await readFile(promptCapturePath, "utf8");
|
||||
expect(capturedPrompt).toBe(SUSPEND_MESSAGE);
|
||||
} finally {
|
||||
if (originalCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalCasDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("resume with -p appends supplementary info to agent prompt", async () => {
|
||||
const originalCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
const { casDir, mockAgentPath, promptCapturePath } = await setupSuspendedThread("ok");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
try {
|
||||
const supplement = "Use the REST API.";
|
||||
const result = runUwf(
|
||||
["thread", "resume", THREAD_ID, "-p", supplement, "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const capturedPrompt = await readFile(promptCapturePath, "utf8");
|
||||
expect(capturedPrompt).toBe(`${SUSPEND_MESSAGE}\n\n${supplement}`);
|
||||
} finally {
|
||||
if (originalCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalCasDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("multiple suspend/resume cycles", async () => {
|
||||
const originalCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
const { casDir, mockAgentPath, promptCapturePath } = await setupSuspendedThread("suspend");
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
try {
|
||||
const firstResult = runUwf(["thread", "resume", THREAD_ID, "--agent", mockAgentPath], casDir);
|
||||
expect(firstResult.status).toBe(0);
|
||||
const firstResume = JSON.parse(firstResult.stdout.trim());
|
||||
expect(firstResume.status).toBe("suspended");
|
||||
expect(firstResume.suspendedRole).toBe("worker");
|
||||
expect(firstResume.suspendMessage).toBe(SUSPEND_MESSAGE);
|
||||
|
||||
const threadsAfterFirst = parse(
|
||||
await readFile(join(tmpDir, "threads.yaml"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(threadsAfterFirst[THREAD_ID]).toEqual({
|
||||
head: firstResume.head,
|
||||
suspendedRole: "worker",
|
||||
suspendMessage: SUSPEND_MESSAGE,
|
||||
});
|
||||
|
||||
const { mockAgentPath: okMockAgentPath } = await setupOkMockAgent(
|
||||
casDir,
|
||||
firstResume.head as CasRef,
|
||||
);
|
||||
|
||||
const secondResult = runUwf(
|
||||
["thread", "resume", THREAD_ID, "--agent", okMockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(secondResult.status).toBe(0);
|
||||
const secondResume = JSON.parse(secondResult.stdout.trim());
|
||||
expect(secondResume.status).toBe("idle");
|
||||
expect(secondResume.currentRole).toBe("reviewer");
|
||||
expect(secondResume.suspendedRole).toBeNull();
|
||||
expect(secondResume.suspendMessage).toBeNull();
|
||||
|
||||
const capturedPrompt = await readFile(promptCapturePath, "utf8");
|
||||
expect(capturedPrompt).toBe(SUSPEND_MESSAGE);
|
||||
} finally {
|
||||
if (originalCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalCasDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function setupOkMockAgent(
|
||||
casDir: string,
|
||||
prevHead: CasRef,
|
||||
): Promise<{ mockAgentPath: string }> {
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
|
||||
|
||||
const prevNode = store.get(prevHead);
|
||||
if (prevNode === null || prevNode.type !== schemas.stepNode) {
|
||||
throw new Error(`expected StepNode at ${prevHead}`);
|
||||
}
|
||||
const prevPayload = prevNode.payload as StepNodePayload;
|
||||
|
||||
const outputHash = await store.put(outputSchemaHash, { $status: "ok" });
|
||||
const detailHash = await store.put(schemas.text, "ok detail");
|
||||
const startedAtMs = Date.now();
|
||||
const completedAtMs = startedAtMs + 1;
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: prevPayload.start,
|
||||
prev: prevHead,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-mock",
|
||||
edgePrompt: "resume",
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
cwd: tmpDir,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const promptCapturePath = join(tmpDir, "captured-prompt.txt");
|
||||
const mockAgentPath = join(tmpDir, "mock-agent-ok.sh");
|
||||
const adapterJson = JSON.stringify({
|
||||
stepHash,
|
||||
detailHash,
|
||||
role: "worker",
|
||||
frontmatter: { $status: "ok" },
|
||||
body: "",
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
});
|
||||
|
||||
await writeFile(
|
||||
mockAgentPath,
|
||||
`#!/bin/sh
|
||||
prompt=""
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--prompt" ]; then
|
||||
prompt="$2"
|
||||
shift 2
|
||||
else
|
||||
shift
|
||||
fi
|
||||
done
|
||||
printf '%s' "$prompt" > '${promptCapturePath}'
|
||||
echo '${adapterJson}'
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
return { mockAgentPath };
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { createMarker, deleteMarker } from "../background/index.js";
|
||||
import { cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
loadThreadsIndex,
|
||||
saveThreadsIndex,
|
||||
} from "../store.js";
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$status: { type: "string" as const },
|
||||
question: { type: "string" as const },
|
||||
},
|
||||
};
|
||||
|
||||
const TEST_WORKFLOW_YAML = `
|
||||
name: test-status
|
||||
description: Test workflow for status field
|
||||
roles:
|
||||
planner:
|
||||
description: Plans the work
|
||||
goal: Plan implementation
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan the work"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const SUSPEND_WORKFLOW_YAML = `
|
||||
name: test-suspend-status
|
||||
description: Test workflow for suspended status
|
||||
roles:
|
||||
worker:
|
||||
description: Worker role
|
||||
goal: Work
|
||||
capabilities: ["coding"]
|
||||
procedure: Work
|
||||
output: |
|
||||
$status: "needs_input"
|
||||
question: "Which API?"
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
required: ["$status", "question"]
|
||||
properties:
|
||||
$status: { const: "needs_input" }
|
||||
question: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: worker
|
||||
prompt: "Start work"
|
||||
location: null
|
||||
worker:
|
||||
needs_input:
|
||||
role: $SUSPEND
|
||||
prompt: "Please clarify: {{{question}}}"
|
||||
location: null
|
||||
`;
|
||||
|
||||
async function insertStepNode(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
outputPayload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headEntry = index[threadId];
|
||||
if (headEntry === undefined) throw new Error(`thread ${threadId} not in index`);
|
||||
const head = headEntry.head;
|
||||
|
||||
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
|
||||
const outputHash = await uwf.store.put(outputSchemaHash, outputPayload);
|
||||
const detailHash = await uwf.store.put(uwf.schemas.text, "detail-placeholder");
|
||||
|
||||
const headNode = uwf.store.get(head);
|
||||
if (headNode === null) throw new Error(`head ${head} not found`);
|
||||
const isStart = headNode.type === uwf.schemas.startNode;
|
||||
const startHash = isStart ? head : (headNode.payload as { start: CasRef }).start;
|
||||
|
||||
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: isStart ? null : head,
|
||||
role,
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "edge",
|
||||
startedAtMs: Date.now(),
|
||||
completedAtMs: Date.now() + 1,
|
||||
cwd: "/tmp",
|
||||
assembledPrompt: null,
|
||||
})) as CasRef;
|
||||
|
||||
index[threadId] = { head: stepHash, suspendedRole: null, suspendMessage: null };
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
}
|
||||
|
||||
describe("thread show status field", () => {
|
||||
let tmpDir: string;
|
||||
let storageRoot: string;
|
||||
|
||||
async function setupTestEnv() {
|
||||
tmpDir = join(tmpdir(), `uwf-test-status-${Date.now()}`);
|
||||
storageRoot = join(tmpDir, "storage");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (tmpDir) {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("active idle thread shows status 'idle'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
|
||||
// Show the thread (should be idle)
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("idle");
|
||||
expect(result.done).toBe(false);
|
||||
expect(result.background).toBe(null);
|
||||
expect(result.thread).toBe(threadId);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("active running thread shows status 'running'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
const workflow = startResult.workflow;
|
||||
|
||||
// Create a running marker
|
||||
await createMarker(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("running");
|
||||
expect(result.done).toBe(false);
|
||||
expect(result.background).toBe(null);
|
||||
expect(result.thread).toBe(threadId);
|
||||
} finally {
|
||||
// Cleanup: delete marker
|
||||
await deleteMarker(storageRoot, threadId);
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("completed thread shows status 'completed'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
const workflow = startResult.workflow;
|
||||
|
||||
// Get the head hash before moving to history
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId]!.head;
|
||||
if (!head) throw new Error("Thread not found in index");
|
||||
|
||||
// Move thread to history with reason 'completed'
|
||||
const { saveThreadsIndex } = await import("../store.js");
|
||||
const newIndex = { ...index };
|
||||
delete newIndex[threadId];
|
||||
await saveThreadsIndex(storageRoot, newIndex);
|
||||
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.background).toBe(null);
|
||||
expect(result.thread).toBe(threadId);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("cancelled thread shows status 'cancelled'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
const workflow = startResult.workflow;
|
||||
|
||||
// Get the head hash before moving to history
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId]!.head;
|
||||
if (!head) throw new Error("Thread not found in index");
|
||||
|
||||
// Move thread to history with reason 'cancelled'
|
||||
const { saveThreadsIndex } = await import("../store.js");
|
||||
const newIndex = { ...index };
|
||||
delete newIndex[threadId];
|
||||
await saveThreadsIndex(storageRoot, newIndex);
|
||||
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "cancelled",
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("cancelled");
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.background).toBe(null);
|
||||
expect(result.thread).toBe(threadId);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("legacy completed thread without reason shows status 'completed'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
const workflow = startResult.workflow;
|
||||
|
||||
// Get the head hash before moving to history
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId]!.head;
|
||||
if (!head) throw new Error("Thread not found in index");
|
||||
|
||||
// Move thread to history with reason null (legacy format)
|
||||
const { saveThreadsIndex } = await import("../store.js");
|
||||
const newIndex = { ...index };
|
||||
delete newIndex[threadId];
|
||||
await saveThreadsIndex(storageRoot, newIndex);
|
||||
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.background).toBe(null);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("active suspended thread shows status 'suspended'", async () => {
|
||||
await setupTestEnv();
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const originalCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
try {
|
||||
const workflowPath = join(tmpDir, "test-suspend-status.yaml");
|
||||
await writeFile(workflowPath, SUSPEND_WORKFLOW_YAML, "utf8");
|
||||
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
|
||||
await insertStepNode(storageRoot, threadId, "worker", {
|
||||
$status: "needs_input",
|
||||
question: "Which API?",
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("suspended");
|
||||
expect(result.done).toBe(false);
|
||||
expect(result.currentRole).toBe(null);
|
||||
expect(result.suspendedRole).toBe("worker");
|
||||
expect(result.suspendMessage).toBe("Please clarify: Which API?");
|
||||
expect(result.background).toBe(null);
|
||||
expect(result.thread).toBe(threadId);
|
||||
} finally {
|
||||
if (originalCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalCasDir;
|
||||
}
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { cmdThreadStart } from "../commands/thread.js";
|
||||
import { createUwfStore, loadThreadsIndex } from "../store.js";
|
||||
|
||||
describe("thread start --cwd CLI option", () => {
|
||||
let tmpDir: string;
|
||||
let storageRoot: string;
|
||||
let casDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
async function setupTestEnv() {
|
||||
tmpDir = join(tmpdir(), `uwf-test-cwd-cli-${Date.now()}`);
|
||||
storageRoot = join(tmpDir, "storage");
|
||||
casDir = join(tmpDir, "cas");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
await mkdir(casDir, { recursive: true });
|
||||
|
||||
// Set UNCAGED_CAS_DIR for this test
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (tmpDir) {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
// Restore original environment
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTestWorkflow(): Promise<string> {
|
||||
const workflowYaml = `
|
||||
name: test-cwd-cli
|
||||
description: Test workflow for CLI cwd option
|
||||
roles:
|
||||
planner:
|
||||
description: Plans the work
|
||||
goal: Plan implementation
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan the work"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-cwd-cli.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
return workflowPath;
|
||||
}
|
||||
|
||||
async function getStartNodeCwd(threadId: string): Promise<string> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId as ThreadId]!.head;
|
||||
expect(headHash).toBeDefined();
|
||||
|
||||
const startNode = uwf.store.get(headHash as CasRef);
|
||||
expect(startNode).not.toBe(null);
|
||||
expect(startNode?.type).toBe(uwf.schemas.startNode);
|
||||
|
||||
const startPayload = startNode?.payload as StartNodePayload;
|
||||
return startPayload.cwd;
|
||||
}
|
||||
|
||||
test("thread start with custom cwd via cmdThreadStart", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = await createTestWorkflow();
|
||||
const testCwd = "/test/custom/path";
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
|
||||
|
||||
expect(result.thread).toBeDefined();
|
||||
const actualCwd = await getStartNodeCwd(result.thread);
|
||||
expect(actualCwd).toBe(testCwd);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start without cwd defaults to process.cwd()", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = await createTestWorkflow();
|
||||
|
||||
// Call without cwd parameter (it defaults to process.cwd())
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
|
||||
expect(result.thread).toBeDefined();
|
||||
const actualCwd = await getStartNodeCwd(result.thread);
|
||||
expect(actualCwd).toBe(process.cwd());
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start with relative path fails", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = await createTestWorkflow();
|
||||
|
||||
await expect(
|
||||
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
|
||||
).rejects.toThrow();
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("CLI accepts --cwd option without error", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = await createTestWorkflow();
|
||||
const testCwd = "/test/cli/path";
|
||||
const uwfBin = join(process.cwd(), "dist", "cli.js");
|
||||
|
||||
// Register the workflow
|
||||
execFileSync("bun", [uwfBin, "workflow", "add", workflowPath], {
|
||||
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
// Verify CLI accepts --cwd option (no error thrown)
|
||||
const output = execFileSync(
|
||||
"bun",
|
||||
[uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd],
|
||||
{
|
||||
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
const result = JSON.parse(output);
|
||||
expect(result.thread).toBeDefined();
|
||||
expect(result.workflow).toBeDefined();
|
||||
|
||||
// The fact that we got here without throwing means CLI accepted the --cwd option
|
||||
// The actual cwd functionality is tested by the other tests using cmdThreadStart directly
|
||||
await teardown();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const CLI_PATH = join(import.meta.dirname, "..", "cli.js");
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
import { cmdThreadShow } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import { saveThreadsIndex } from "../store.js";
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$status: { type: "string" as const },
|
||||
question: { type: "string" as const },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-suspend-step-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("suspend step CAS chain and threads.yaml metadata", () => {
|
||||
test("thread exec records suspend step in CAS and suspend metadata in threads.yaml", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const originalCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
try {
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
|
||||
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-suspend-step",
|
||||
description: "suspend step integration test",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker role",
|
||||
goal: "Work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: outputSchemaHash,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "Start work", location: null } },
|
||||
worker: {
|
||||
needs_input: {
|
||||
role: "$SUSPEND",
|
||||
prompt: "Please clarify: {{{question}}}",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test suspend task",
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const threadId = "01SUSPENDSTEPTEST0000000" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: startHash });
|
||||
|
||||
const outputHash = await store.put(outputSchemaHash, {
|
||||
$status: "needs_input",
|
||||
question: "Which API?",
|
||||
});
|
||||
const detailHash = await store.put(schemas.text, "mock detail");
|
||||
|
||||
const startedAtMs = 1716600000000;
|
||||
const completedAtMs = 1716600001500;
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-mock",
|
||||
edgePrompt: "Start work",
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
cwd: tmpDir,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const mockAgentPath = join(tmpDir, "mock-agent.sh");
|
||||
const adapterJson = JSON.stringify({
|
||||
stepHash,
|
||||
detailHash,
|
||||
role: "worker",
|
||||
frontmatter: { $status: "needs_input", question: "Which API?" },
|
||||
body: "",
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
});
|
||||
await writeFile(mockAgentPath, `#!/bin/sh\necho '${adapterJson}'\n`, { mode: 0o755 });
|
||||
|
||||
const configPath = join(tmpDir, "config.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
|
||||
);
|
||||
|
||||
const cliPath = join(import.meta.dirname, "..", "cli.js");
|
||||
const stdout = execFileSync(
|
||||
"bun",
|
||||
["run", cliPath, "thread", "exec", threadId, "--agent", mockAgentPath],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
WORKFLOW_STORAGE_ROOT: tmpDir,
|
||||
UNCAGED_CAS_DIR: casDir,
|
||||
},
|
||||
cwd: tmpDir,
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
const cliOutput = JSON.parse(stdout.trim());
|
||||
expect(cliOutput.status).toBe("suspended");
|
||||
expect(cliOutput.head).toBe(stepHash);
|
||||
expect(cliOutput.suspendedRole).toBe("worker");
|
||||
expect(cliOutput.suspendMessage).toBe("Please clarify: Which API?");
|
||||
|
||||
const storeAfter = createFsStore(casDir);
|
||||
const stepNode = storeAfter.get(cliOutput.head as CasRef);
|
||||
expect(stepNode).not.toBeNull();
|
||||
const payload = stepNode!.payload as StepNodePayload;
|
||||
expect(payload.role).toBe("worker");
|
||||
expect(payload.output).toBe(outputHash);
|
||||
|
||||
const outputNode = storeAfter.get(outputHash);
|
||||
expect(outputNode?.payload).toEqual({
|
||||
$status: "needs_input",
|
||||
question: "Which API?",
|
||||
});
|
||||
|
||||
const threadsYaml = await readFile(join(tmpDir, "threads.yaml"), "utf8");
|
||||
const threadsIndex = parse(threadsYaml) as Record<string, unknown>;
|
||||
const threadEntry = threadsIndex[threadId];
|
||||
expect(threadEntry).toEqual({
|
||||
head: stepHash,
|
||||
suspendedRole: "worker",
|
||||
suspendMessage: "Please clarify: Which API?",
|
||||
});
|
||||
|
||||
const showResult = await cmdThreadShow(tmpDir, threadId);
|
||||
expect(showResult.status).toBe("suspended");
|
||||
expect(showResult.suspendMessage).toBe("Please clarify: Which API?");
|
||||
expect(showResult.suspendedRole).toBe("worker");
|
||||
} finally {
|
||||
if (originalCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalCasDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { createThreadIndexEntry, markThreadSuspended } from "@uncaged/workflow-protocol";
|
||||
import { cmdThreadList, cmdThreadShow } from "../commands/thread.js";
|
||||
import { createUwfStore, saveThreadsIndex } from "../store.js";
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$status: { type: "string" as const },
|
||||
question: { type: "string" as const },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-suspended-display-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("suspended thread display", () => {
|
||||
test("thread list shows [suspended] marker for suspended threads", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const originalCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
try {
|
||||
const uwf = await createUwfStore(tmpDir);
|
||||
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
|
||||
|
||||
// Create test workflow with suspend capability
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-suspend-display",
|
||||
description: "test suspended display",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker role",
|
||||
goal: "Work and potentially suspend",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: outputSchemaHash,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "Start work", location: null } },
|
||||
worker: {
|
||||
needs_input: {
|
||||
role: "$SUSPEND",
|
||||
prompt: "Please provide more details: {{{question}}}",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task requiring input",
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
// Create suspended thread
|
||||
const suspendedThreadId = "01SUSPENDEDTHREAD0000000" as ThreadId;
|
||||
const outputHash = await uwf.store.put(outputSchemaHash, {
|
||||
$status: "needs_input",
|
||||
question: "What is the target API?",
|
||||
});
|
||||
const detailHash = await uwf.store.put(uwf.schemas.text, "mock detail");
|
||||
|
||||
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-mock",
|
||||
edgePrompt: "Start work",
|
||||
startedAtMs: 1716600000000,
|
||||
completedAtMs: 1716600001500,
|
||||
cwd: tmpDir,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
// Create suspended thread entry in threads.yaml
|
||||
const suspendedEntry = markThreadSuspended(
|
||||
createThreadIndexEntry(stepHash),
|
||||
"worker",
|
||||
"Please provide more details: What is the target API?",
|
||||
);
|
||||
|
||||
// Create normal (idle) thread
|
||||
const idleThreadId = "01IDLETHREAD00000000000" as ThreadId;
|
||||
const idleStartHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Normal task",
|
||||
cwd: tmpDir,
|
||||
});
|
||||
const idleEntry = createThreadIndexEntry(idleStartHash);
|
||||
|
||||
await saveThreadsIndex(tmpDir, {
|
||||
[suspendedThreadId]: suspendedEntry,
|
||||
[idleThreadId]: idleEntry,
|
||||
});
|
||||
|
||||
// Test thread list
|
||||
const listResult = await cmdThreadList(tmpDir, null, null, null, null, null);
|
||||
|
||||
// Find the suspended and idle threads in results
|
||||
const suspendedItem = listResult.find((item) => item.thread === suspendedThreadId);
|
||||
const idleItem = listResult.find((item) => item.thread === idleThreadId);
|
||||
|
||||
expect(suspendedItem).toBeDefined();
|
||||
expect(suspendedItem!.status).toBe("suspended");
|
||||
expect(suspendedItem!.statusDisplay).toBe("suspended [suspended]");
|
||||
|
||||
expect(idleItem).toBeDefined();
|
||||
expect(idleItem!.status).toBe("idle");
|
||||
expect(idleItem!.statusDisplay).toBe("idle");
|
||||
} finally {
|
||||
if (originalCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalCasDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("thread show displays suspend info and resume hint", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const originalCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
try {
|
||||
const uwf = await createUwfStore(tmpDir);
|
||||
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-suspend-show",
|
||||
description: "test suspended show",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker role",
|
||||
goal: "Work and potentially suspend",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: outputSchemaHash,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "Start work", location: null } },
|
||||
worker: {
|
||||
needs_input: {
|
||||
role: "$SUSPEND",
|
||||
prompt: "Need clarification: {{{question}}}",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const threadId = "01SUSPENDSHOW000000000" as ThreadId;
|
||||
const outputHash = await uwf.store.put(outputSchemaHash, {
|
||||
$status: "needs_input",
|
||||
question: "Which database to use?",
|
||||
});
|
||||
const detailHash = await uwf.store.put(uwf.schemas.text, "mock detail");
|
||||
|
||||
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-mock",
|
||||
edgePrompt: "Start work",
|
||||
startedAtMs: 1716600000000,
|
||||
completedAtMs: 1716600001500,
|
||||
cwd: tmpDir,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const suspendedEntry = markThreadSuspended(
|
||||
createThreadIndexEntry(stepHash),
|
||||
"worker",
|
||||
"Need clarification: Which database to use?",
|
||||
);
|
||||
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: suspendedEntry });
|
||||
|
||||
// Test thread show
|
||||
const showResult = await cmdThreadShow(tmpDir, threadId);
|
||||
|
||||
expect(showResult.status).toBe("suspended");
|
||||
expect(showResult.suspendedRole).toBe("worker");
|
||||
expect(showResult.suspendMessage).toBe("Need clarification: Which database to use?");
|
||||
expect(showResult.hint).toBe(
|
||||
`Thread is suspended. Resume with: uwf thread resume ${threadId}`,
|
||||
);
|
||||
} finally {
|
||||
if (originalCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalCasDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("non-suspended threads do not show suspend markers or hints", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const originalCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
|
||||
try {
|
||||
const uwf = await createUwfStore(tmpDir);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-normal",
|
||||
description: "test normal thread",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker role",
|
||||
goal: "Work normally",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "Start work", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Normal task",
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const threadId = "01NORMALTHREAD000000000" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: createThreadIndexEntry(startHash) });
|
||||
|
||||
// Test thread show
|
||||
const showResult = await cmdThreadShow(tmpDir, threadId);
|
||||
|
||||
expect(showResult.status).toBe("idle");
|
||||
expect(showResult.suspendedRole).toBeNull();
|
||||
expect(showResult.suspendMessage).toBeNull();
|
||||
expect(showResult.hint).toBeNull();
|
||||
|
||||
// Test thread list
|
||||
const listResult = await cmdThreadList(tmpDir, null, null, null, null, null);
|
||||
const threadItem = listResult.find((item) => item.thread === threadId);
|
||||
|
||||
expect(threadItem).toBeDefined();
|
||||
expect(threadItem!.status).toBe("idle");
|
||||
expect(threadItem!.statusDisplay).toBe("idle");
|
||||
} finally {
|
||||
if (originalCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalCasDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { bootstrap, putSchema } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdStepList, cmdStepShow } from "../commands/step.js";
|
||||
import {
|
||||
cmdThreadRead,
|
||||
@@ -47,7 +47,7 @@ const DETAIL_SCHEMA = {
|
||||
turnCount: { type: "integer" as const },
|
||||
turns: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const, format: "cas_ref" },
|
||||
items: { type: "string" as const, format: "ocas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -58,6 +58,8 @@ const DETAIL_SCHEMA = {
|
||||
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
// Set UNCAGED_CAS_DIR to use the test's CAS directory
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
return { storageRoot, store, schemas };
|
||||
@@ -758,6 +760,7 @@ describe("cmdStepList with completed threads", () => {
|
||||
workflow: workflowHash,
|
||||
head: step2Hash,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const result = await cmdStepList(tmpDir, threadId);
|
||||
@@ -886,6 +889,7 @@ describe("cmdStepShow with completed threads", () => {
|
||||
workflow: workflowHash,
|
||||
head: stepHash,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const result = await cmdStepShow(tmpDir, stepHash);
|
||||
@@ -949,6 +953,7 @@ describe("cmdThreadRead with completed threads", () => {
|
||||
workflow: workflowHash,
|
||||
head: stepHash,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
@@ -1011,6 +1016,7 @@ describe("cmdThreadRead with completed threads", () => {
|
||||
workflow: workflowHash,
|
||||
head: step3Hash,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const markdown = await cmdThreadRead(
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { validateWorkflow } from "../validate-semantic.js";
|
||||
|
||||
/** Build a valid two-role workflow that passes all checks. */
|
||||
function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
|
||||
const base: WorkflowPayload = {
|
||||
name: "test-workflow",
|
||||
description: "A test workflow",
|
||||
roles: {
|
||||
writer: {
|
||||
description: "Writes content",
|
||||
goal: "Write content",
|
||||
capabilities: ["writing"],
|
||||
procedure: "Write it",
|
||||
output: "The content",
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { enum: ["_"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["$status", "plan"],
|
||||
} as unknown as string,
|
||||
},
|
||||
reviewer: {
|
||||
description: "Reviews content",
|
||||
goal: "Review content",
|
||||
capabilities: ["reviewing"],
|
||||
procedure: "Review it",
|
||||
output: "The review",
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
$status: { const: "approved" },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
required: ["$status", "summary"],
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
$status: { const: "rejected" },
|
||||
reason: { type: "string" },
|
||||
},
|
||||
required: ["$status", "reason"],
|
||||
},
|
||||
],
|
||||
} as unknown as string,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "writer", prompt: "Begin writing", location: null } },
|
||||
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } },
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (!overrides) return base;
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
describe("Suite 1: Role Reference Integrity", () => {
|
||||
test("1.1 graph references unknown role", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true);
|
||||
});
|
||||
|
||||
test("1.2 orphan role not in graph", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.roles.orphan = {
|
||||
description: "Orphan",
|
||||
goal: "Nothing",
|
||||
capabilities: [],
|
||||
procedure: "None",
|
||||
output: "None",
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: { $status: { enum: ["_"] } },
|
||||
required: ["$status"],
|
||||
} as unknown as string,
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('role "orphan" is defined but not referenced in graph')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("1.3 $START in roles", () => {
|
||||
const wf = makeWorkflow();
|
||||
(wf.roles as Record<string, unknown>).$START = {
|
||||
description: "Bad",
|
||||
goal: "Bad",
|
||||
capabilities: [],
|
||||
procedure: "Bad",
|
||||
output: "Bad",
|
||||
frontmatter: { type: "object", properties: {}, required: [] },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('reserved name "$START"'))).toBe(true);
|
||||
});
|
||||
|
||||
test("1.4 $END in roles", () => {
|
||||
const wf = makeWorkflow();
|
||||
(wf.roles as Record<string, unknown>).$END = {
|
||||
description: "Bad",
|
||||
goal: "Bad",
|
||||
capabilities: [],
|
||||
procedure: "Bad",
|
||||
output: "Bad",
|
||||
frontmatter: { type: "object", properties: {}, required: [] },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('reserved name "$END"'))).toBe(true);
|
||||
});
|
||||
|
||||
test("1.5 valid workflow returns no errors", () => {
|
||||
const wf = makeWorkflow();
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 2: Graph Structure", () => {
|
||||
test("2.1 $START missing from graph", () => {
|
||||
const wf = makeWorkflow();
|
||||
delete wf.graph.$START;
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("$START must be defined in graph"))).toBe(true);
|
||||
});
|
||||
|
||||
test("2.2 $START has multiple status keys", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.$START = {
|
||||
_: { role: "writer", prompt: "Begin", location: null },
|
||||
other: { role: "reviewer", prompt: "Also", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("2.3 $START edge uses non-_ status", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.$START = { ready: { role: "writer", prompt: "Begin", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("2.4 $END has outgoing edges", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.$END = { _: { role: "writer", prompt: "Loop", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true);
|
||||
});
|
||||
|
||||
test("2.5 unreachable role", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.roles.isolated = {
|
||||
description: "Isolated",
|
||||
goal: "Isolated",
|
||||
capabilities: [],
|
||||
procedure: "Isolated",
|
||||
output: "Isolated",
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: { $status: { enum: ["_"] } },
|
||||
required: ["$status"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.isolated = { _: { role: "$END", prompt: "done", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("2.6 edge target references invalid role", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 3: Status-Edge Consistency", () => {
|
||||
test("3.1 single-exit role with multiple graph keys", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = {
|
||||
_: { role: "reviewer", prompt: "Review", location: null },
|
||||
extra: { role: "$END", prompt: "Done", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) =>
|
||||
e.includes('role "writer" is single-exit but has status keys other than "_"'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("3.2 single-exit role missing _ key", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { done: { role: "reviewer", prompt: "Review", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("3.3 multi-exit role with extra statuses", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix", location: null },
|
||||
timeout: { role: "$END", prompt: "Timed out", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('role "reviewer" graph has extra status keys: timeout')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("3.4 multi-exit role missing a status", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('role "reviewer" graph is missing status keys: rejected')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("3.5 multi-exit role with _ key", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
test("3b.1 enum multi-exit passes with matching graph keys", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.roles.reviewer = {
|
||||
...wf.roles.reviewer,
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { enum: ["approved", "rejected"] },
|
||||
comments: { type: "string" },
|
||||
},
|
||||
required: ["$status", "comments"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("3b.2 enum multi-exit with extra graph key", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.roles.reviewer = {
|
||||
...wf.roles.reviewer,
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { enum: ["approved", "rejected"] },
|
||||
comments: { type: "string" },
|
||||
},
|
||||
required: ["$status", "comments"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix", location: null },
|
||||
timeout: { role: "$END", prompt: "Timed out", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true);
|
||||
});
|
||||
|
||||
test("3b.3 enum multi-exit with missing graph key", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.roles.reviewer = {
|
||||
...wf.roles.reviewer,
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { enum: ["approved", "rejected"] },
|
||||
comments: { type: "string" },
|
||||
},
|
||||
required: ["$status", "comments"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
|
||||
});
|
||||
|
||||
test("3b.4 enum with single value (not multi-exit) treated as single-exit", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.roles.writer = {
|
||||
...wf.roles.writer,
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { enum: ["_"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["$status", "plan"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("3b.5 enum multi-exit mustache var not in frontmatter", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.roles.reviewer = {
|
||||
...wf.roles.reviewer,
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { enum: ["approved", "rejected"] },
|
||||
comments: { type: "string" },
|
||||
},
|
||||
required: ["$status", "comments"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 4: Mustache Template Variable Existence", () => {
|
||||
test("4.1 prompt references nonexistent variable (single-exit)", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) =>
|
||||
e.includes('prompt variable "branch" not found in role "writer" frontmatter'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("4.2 prompt references nonexistent variable (multi-exit)", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done: {{{branch}}}", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) =>
|
||||
e.includes('prompt variable "branch" not found in role "reviewer" variant "approved"'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("4.3 valid mustache variables pass", () => {
|
||||
const wf = makeWorkflow();
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("4.4 $status variable is always valid", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 5: oneOf Discriminant Validity", () => {
|
||||
test("5.1 oneOf without $status const", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.roles.reviewer = {
|
||||
...wf.roles.reviewer,
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{ properties: { summary: { type: "string" } }, required: ["summary"] },
|
||||
{ properties: { reason: { type: "string" } }, required: ["reason"] },
|
||||
],
|
||||
} as unknown as string,
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('oneOf variants must have "$status" as const discriminant')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("5.2 oneOf with non-const $status", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.roles.reviewer = {
|
||||
...wf.roles.reviewer,
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
properties: { $status: { type: "string" }, summary: { type: "string" } },
|
||||
required: ["$status", "summary"],
|
||||
},
|
||||
{
|
||||
properties: { $status: { type: "string" }, reason: { type: "string" } },
|
||||
required: ["$status", "reason"],
|
||||
},
|
||||
],
|
||||
} as unknown as string,
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("oneOf variant $status must be a const value"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("5.3 valid oneOf passes", () => {
|
||||
const wf = makeWorkflow();
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 6: Multiple Errors Collection", () => {
|
||||
test("6.1 multiple errors collected", () => {
|
||||
const wf = makeWorkflow();
|
||||
// orphan role
|
||||
wf.roles.orphan = {
|
||||
description: "Orphan",
|
||||
goal: "Nothing",
|
||||
capabilities: [],
|
||||
procedure: "None",
|
||||
output: "None",
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: { $status: { enum: ["_"] } },
|
||||
required: ["$status"],
|
||||
} as unknown as string,
|
||||
};
|
||||
// unknown graph reference
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
|
||||
// bad mustache var
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { stringify } from "yaml";
|
||||
import { cmdThreadStart } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
@@ -15,28 +15,50 @@ import { loadWorkflowRegistry, saveWorkflowRegistry } from "../store.js";
|
||||
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
// Set UNCAGED_CAS_DIR to use the test's CAS directory
|
||||
process.env.UNCAGED_CAS_DIR = casDir;
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
return { storageRoot, store, schemas };
|
||||
}
|
||||
|
||||
async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
||||
const payload: WorkflowPayload = {
|
||||
function makeMinimalPayload(name: string, description: string): WorkflowPayload {
|
||||
return {
|
||||
name,
|
||||
description: "Test workflow",
|
||||
roles: {},
|
||||
graph: {},
|
||||
description,
|
||||
roles: {
|
||||
worker: {
|
||||
description: "worker role",
|
||||
goal: "do work",
|
||||
capabilities: [],
|
||||
procedure: "",
|
||||
output: "",
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { type: "string" },
|
||||
},
|
||||
required: ["$status"],
|
||||
} as unknown as CasRef,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "start working", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "done", location: null } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
||||
const payload = makeMinimalPayload(name, "Test workflow");
|
||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||
}
|
||||
|
||||
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
|
||||
const payload: WorkflowPayload = {
|
||||
const payload = makeMinimalPayload(
|
||||
name,
|
||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||
roles: {},
|
||||
graph: {},
|
||||
};
|
||||
version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||
);
|
||||
const yaml = stringify(payload);
|
||||
return yaml;
|
||||
}
|
||||
@@ -235,6 +257,49 @@ describe("Strategy 3: Local Discovery", () => {
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should find workflow in folder-based layout (name/index.yaml)", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow", "solve-issue");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(join(workflowDir, "index.yaml"), await createWorkflowYaml("solve-issue"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).name).toBe("solve-issue");
|
||||
}
|
||||
});
|
||||
|
||||
test("should prefer flat file over folder-based layout", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(workflowDir, "solve-issue.yaml"),
|
||||
await createWorkflowYaml("solve-issue", "flat"),
|
||||
);
|
||||
|
||||
const folderDir = join(workflowDir, "solve-issue");
|
||||
await mkdir(folderDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(folderDir, "index.yaml"),
|
||||
await createWorkflowYaml("solve-issue", "folder"),
|
||||
);
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (flat)");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Strategy 4: Global Registry Fallback ──────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
#!/usr/bin/env node
|
||||
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import type { CasRef, ThreadId, ThreadStatus } from "@uncaged/workflow-protocol";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
cmdCasGet,
|
||||
@@ -13,20 +13,30 @@ import {
|
||||
cmdCasSchemaList,
|
||||
cmdCasWalk,
|
||||
} from "./commands/cas.js";
|
||||
import { cmdConfigGet, cmdConfigList, cmdConfigSet } from "./commands/config.js";
|
||||
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
||||
import {
|
||||
cmdPromptAdapter,
|
||||
cmdPromptAuthor,
|
||||
cmdPromptBootstrap,
|
||||
cmdPromptDeveloper,
|
||||
cmdPromptList,
|
||||
cmdPromptSetup,
|
||||
cmdPromptUsage,
|
||||
cmdPromptUser,
|
||||
} from "./commands/prompt.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import { cmdSkillCli } from "./commands/skill.js";
|
||||
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||
import {
|
||||
cmdThreadCancel,
|
||||
cmdThreadExec,
|
||||
cmdThreadList,
|
||||
cmdThreadRead,
|
||||
cmdThreadResume,
|
||||
cmdThreadShow,
|
||||
cmdThreadStart,
|
||||
cmdThreadStop,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
type ThreadStatus,
|
||||
} from "./commands/thread.js";
|
||||
import { parseTimeInput } from "./commands/thread-time-parser.js";
|
||||
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
||||
@@ -55,8 +65,7 @@ program
|
||||
.description(
|
||||
"Stateless workflow CLI\n\n" +
|
||||
"Four-layer architecture:\n" +
|
||||
" workflow → thread → step → turn\n" +
|
||||
" 模板定义 执行实例 单步结果 agent内部交互",
|
||||
" workflow → thread → step → turn",
|
||||
)
|
||||
.version(pkg.default.version, "-V, --version");
|
||||
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||
@@ -107,10 +116,17 @@ thread
|
||||
.description("Create a thread without executing")
|
||||
.argument("<workflow>", "Workflow name or hash")
|
||||
.requiredOption("-p, --prompt <text>", "User prompt")
|
||||
.action((workflow: string, opts: { prompt: string }) => {
|
||||
.option("--cwd <path>", "Working directory for thread execution (default: process.cwd())")
|
||||
.action((workflow: string, opts: { prompt: string; cwd: string | undefined }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt, process.cwd());
|
||||
const result = await cmdThreadStart(
|
||||
storageRoot,
|
||||
workflow,
|
||||
opts.prompt,
|
||||
process.cwd(),
|
||||
opts.cwd ?? process.cwd(),
|
||||
);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
@@ -176,11 +192,11 @@ function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
|
||||
if (raw === "active") return ["idle", "running"];
|
||||
|
||||
const parts = raw.split(",").map((s) => s.trim());
|
||||
const validStatuses: ThreadStatus[] = ["idle", "running", "completed"];
|
||||
const validStatuses: ThreadStatus[] = ["idle", "running", "suspended", "completed", "cancelled"];
|
||||
for (const part of parts) {
|
||||
if (!validStatuses.includes(part as ThreadStatus)) {
|
||||
process.stderr.write(
|
||||
`Invalid status: ${part}. Must be one of: idle, running, completed, active\n`,
|
||||
`Invalid status: ${part}. Must be one of: idle, running, suspended, completed, cancelled, active\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -233,7 +249,7 @@ thread
|
||||
.description("List threads")
|
||||
.option(
|
||||
"--status <status>",
|
||||
"Filter by status: idle, running, completed, active (idle+running), or comma-separated values",
|
||||
"Filter by status: idle, running, completed, cancelled, active (idle+running), or comma-separated values",
|
||||
)
|
||||
.option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
|
||||
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
|
||||
@@ -267,6 +283,27 @@ thread
|
||||
},
|
||||
);
|
||||
|
||||
thread
|
||||
.command("resume")
|
||||
.description("Resume a suspended thread and re-run the suspended role")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("-p, --prompt <text>", "Supplementary info to append to the resume prompt")
|
||||
.option("--agent <cmd>", "Override agent command")
|
||||
.action((threadId: string, opts: { prompt: string | undefined; agent: string | undefined }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const supplement = opts.prompt ?? null;
|
||||
const agentOverride = opts.agent ?? null;
|
||||
const result = await cmdThreadResume(
|
||||
storageRoot,
|
||||
threadId as ThreadId,
|
||||
supplement,
|
||||
agentOverride,
|
||||
);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("stop")
|
||||
.description("Stop background execution of a thread (keep thread active)")
|
||||
@@ -351,7 +388,8 @@ step
|
||||
.description("Read a step's turns as human-readable markdown")
|
||||
.argument("<step-hash>", "CAS hash of the StepNode")
|
||||
.option("--quota <chars>", "Max output characters", "4000")
|
||||
.action((stepHash: string, opts: { quota: string }) => {
|
||||
.option("--prompt", "Show the assembled prompt sent to the agent instead of turns")
|
||||
.action((stepHash: string, opts: { quota: string; prompt: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const quota = Number.parseInt(opts.quota, 10);
|
||||
@@ -359,7 +397,12 @@ step
|
||||
process.stderr.write("invalid --quota: must be a positive integer\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota);
|
||||
const markdown = await cmdStepRead(
|
||||
storageRoot,
|
||||
stepHash as CasRef,
|
||||
quota,
|
||||
opts.prompt === true,
|
||||
);
|
||||
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
||||
});
|
||||
});
|
||||
@@ -473,13 +516,63 @@ For more information, see: uwf help thread list
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const skill = program.command("skill").description("Built-in skill references for agents");
|
||||
const prompt = program.command("prompt").description("Built-in prompt references for agents");
|
||||
prompt.addHelpCommand(false);
|
||||
|
||||
skill
|
||||
.command("cli")
|
||||
.description("Print a markdown reference of all uwf commands")
|
||||
prompt
|
||||
.command("usage")
|
||||
.description("Print the complete skill content (all references combined)")
|
||||
.action(() => {
|
||||
console.log(cmdSkillCli());
|
||||
console.log(cmdPromptUsage());
|
||||
});
|
||||
|
||||
prompt
|
||||
.command("setup")
|
||||
.description("Print setup instructions for installing the uwf skill")
|
||||
.action(() => {
|
||||
console.log(cmdPromptSetup());
|
||||
});
|
||||
|
||||
prompt
|
||||
.command("adapter")
|
||||
.description("Print the adapter reference (building agent adapters)")
|
||||
.action(() => {
|
||||
console.log(cmdPromptAdapter());
|
||||
});
|
||||
|
||||
prompt
|
||||
.command("author")
|
||||
.description("Print the author reference (workflow YAML design guide)")
|
||||
.action(() => {
|
||||
console.log(cmdPromptAuthor());
|
||||
});
|
||||
|
||||
prompt
|
||||
.command("developer")
|
||||
.description("Print the developer reference (coding conventions + architecture)")
|
||||
.action(() => {
|
||||
console.log(cmdPromptDeveloper());
|
||||
});
|
||||
|
||||
prompt
|
||||
.command("user")
|
||||
.description("Print the user reference (CLI guide + typical workflows)")
|
||||
.action(() => {
|
||||
console.log(cmdPromptUser());
|
||||
});
|
||||
|
||||
prompt
|
||||
.command("bootstrap")
|
||||
.description("Print the bootstrap skill YAML for Hermes agents")
|
||||
.action(() => {
|
||||
console.log(cmdPromptBootstrap());
|
||||
});
|
||||
|
||||
prompt
|
||||
.command("list")
|
||||
.description("List all available prompt names")
|
||||
.action(() => {
|
||||
console.log(cmdPromptList().join("\n"));
|
||||
});
|
||||
|
||||
program
|
||||
@@ -489,7 +582,7 @@ program
|
||||
.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")
|
||||
.option("--agent <name>", "Default agent adapter (e.g. hermes → uwf-hermes)")
|
||||
.action(
|
||||
(opts: {
|
||||
provider?: string;
|
||||
@@ -677,6 +770,47 @@ log
|
||||
});
|
||||
});
|
||||
|
||||
const config = program.command("config").description("Configuration management");
|
||||
|
||||
config
|
||||
.command("list")
|
||||
.description("Display all configuration values (masks API keys)")
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdConfigList(storageRoot);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
config
|
||||
.command("get")
|
||||
.description("Get a specific configuration value")
|
||||
.argument(
|
||||
"<key>",
|
||||
"Dot-notation path to config value (e.g., defaultAgent, providers.dashscope.baseUrl)",
|
||||
)
|
||||
.action((key: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdConfigGet(storageRoot, key);
|
||||
writeOutput({ value: result });
|
||||
});
|
||||
});
|
||||
|
||||
config
|
||||
.command("set")
|
||||
.description("Set a specific configuration value")
|
||||
.argument("<key>", "Dot-notation path to config value")
|
||||
.argument("<value>", "New value (use JSON array for 'args' key, e.g., '[\"--flag\"]')")
|
||||
.action((key: string, value: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdConfigSet(storageRoot, key, value);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`${message}\n`);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, putSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { JSONSchema, Store } from "@ocas/core";
|
||||
import { bootstrap, getSchema, putSchema, refs, walk } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
|
||||
import { TEXT_SCHEMA } from "../schemas.js";
|
||||
|
||||
@@ -85,13 +85,17 @@ export type SchemaListEntry = {
|
||||
|
||||
export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListEntry[]> {
|
||||
const store = openStore(storageRoot);
|
||||
const metaHash = await bootstrap(store);
|
||||
const aliases = await bootstrap(store);
|
||||
const metaHash = aliases["@ocas/schema"];
|
||||
if (metaHash === undefined) {
|
||||
throw new Error("Meta-schema not found in bootstrap result");
|
||||
}
|
||||
const entries: SchemaListEntry[] = [];
|
||||
|
||||
// Include meta-schema itself
|
||||
entries.push({ hash: metaHash, title: "(meta-schema)" });
|
||||
|
||||
for (const hash of store.listByType(metaHash)) {
|
||||
for (const { hash } of store.listByType(metaHash)) {
|
||||
if (hash === metaHash) continue;
|
||||
const node = store.get(hash);
|
||||
if (node !== null) {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
/**
|
||||
* Valid configuration key schema
|
||||
*/
|
||||
const VALID_CONFIG_KEYS: Record<
|
||||
string,
|
||||
{ nested: boolean; knownFields?: string[]; minDepth?: number }
|
||||
> = {
|
||||
providers: {
|
||||
nested: true,
|
||||
knownFields: ["baseUrl", "apiKey"],
|
||||
},
|
||||
models: {
|
||||
nested: true,
|
||||
knownFields: ["provider", "name"],
|
||||
},
|
||||
agents: {
|
||||
nested: true,
|
||||
knownFields: ["command", "args"],
|
||||
},
|
||||
agentOverrides: {
|
||||
nested: true,
|
||||
// agentOverrides.<workflowName>.<roleName> = agentAlias (string value)
|
||||
// No knownFields — workflow/role names are user-defined
|
||||
},
|
||||
modelOverrides: {
|
||||
nested: true,
|
||||
minDepth: 2,
|
||||
// modelOverrides.<scenario> = modelAlias (string value)
|
||||
// No knownFields — scenarios are user-defined
|
||||
},
|
||||
defaultAgent: { nested: false },
|
||||
defaultModel: { nested: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a config key path against the known schema
|
||||
*/
|
||||
function validateConfigKey(path: string[]): void {
|
||||
if (path.length === 0) {
|
||||
throw new Error("Path cannot be empty");
|
||||
}
|
||||
|
||||
const topLevel = path[0];
|
||||
const schema = VALID_CONFIG_KEYS[topLevel];
|
||||
|
||||
if (!schema) {
|
||||
const validKeys = Object.keys(VALID_CONFIG_KEYS).join(", ");
|
||||
throw new Error(`Unknown config key: ${topLevel}. Valid top-level keys are: ${validKeys}`);
|
||||
}
|
||||
|
||||
// Scalar keys cannot have nested paths
|
||||
if (!schema.nested && path.length > 1) {
|
||||
throw new Error(`${topLevel} is a scalar key and cannot have nested properties`);
|
||||
}
|
||||
|
||||
// Nested keys must have at least minDepth segments (default 3)
|
||||
const minDepth = schema.minDepth ?? 3;
|
||||
if (schema.nested && path.length < minDepth) {
|
||||
const fields = schema.knownFields?.join(", ") ?? "";
|
||||
throw new Error(
|
||||
`Incomplete path for ${topLevel}. Must specify a field (e.g., ${topLevel}.<name>.<field>). Valid fields: ${fields}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the field name for nested keys
|
||||
if (schema.nested && path.length >= 3 && schema.knownFields) {
|
||||
const field = path[path.length - 1];
|
||||
if (!schema.knownFields.includes(field)) {
|
||||
throw new Error(
|
||||
`Unknown field '${field}' in ${topLevel}. Valid fields are: ${schema.knownFields.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the config.yaml file
|
||||
*/
|
||||
export function getConfigPath(storageRoot: string): string {
|
||||
return join(storageRoot, "config.yaml");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse YAML config file
|
||||
*/
|
||||
export function loadConfig(configPath: string): Record<string, unknown> {
|
||||
if (!existsSync(configPath)) {
|
||||
throw new Error(`Config file not found: ${configPath}`);
|
||||
}
|
||||
const content = readFileSync(configPath, "utf8");
|
||||
if (!content.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = parse(content);
|
||||
return (parsed ?? {}) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid YAML in config file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config as YAML
|
||||
*/
|
||||
export function saveConfig(configPath: string, config: Record<string, unknown>): void {
|
||||
const dir = join(configPath, "..");
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const yaml = stringify(config);
|
||||
writeFileSync(configPath, yaml, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dot-notation key into path segments
|
||||
*/
|
||||
export function parseDotPath(key: string): string[] {
|
||||
return key.split(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value from object using path array
|
||||
*/
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const segment of path) {
|
||||
if (current === null || current === undefined || typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set nested value in object using path array (mutates obj)
|
||||
*/
|
||||
export function setNestedValue(obj: Record<string, unknown>, path: string[], value: unknown): void {
|
||||
if (path.length === 0) {
|
||||
throw new Error("Path cannot be empty");
|
||||
}
|
||||
|
||||
let current: Record<string, unknown> = obj;
|
||||
|
||||
// Navigate/create to the parent of the target
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const segment = path[i];
|
||||
const next = current[segment];
|
||||
|
||||
if (next === null || next === undefined) {
|
||||
// Create intermediate object
|
||||
const newObj: Record<string, unknown> = {};
|
||||
current[segment] = newObj;
|
||||
current = newObj;
|
||||
} else if (typeof next === "object" && !Array.isArray(next)) {
|
||||
// Navigate into existing object
|
||||
current = next as Record<string, unknown>;
|
||||
} else {
|
||||
// Cannot navigate into non-object
|
||||
throw new Error(
|
||||
`Cannot set property '${path[i + 1]}' on non-object at path '${path.slice(0, i + 1).join(".")}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
const lastSegment = path[path.length - 1];
|
||||
current[lastSegment] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone and mask all apiKey values in providers section
|
||||
*/
|
||||
export function maskApiKeys(config: Record<string, unknown>): Record<string, unknown> {
|
||||
// Deep clone
|
||||
const cloned = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
|
||||
|
||||
// Mask apiKey values in providers
|
||||
if (cloned.providers && typeof cloned.providers === "object") {
|
||||
const providers = cloned.providers as Record<string, unknown>;
|
||||
for (const providerName of Object.keys(providers)) {
|
||||
const provider = providers[providerName];
|
||||
if (provider && typeof provider === "object") {
|
||||
const providerObj = provider as Record<string, unknown>;
|
||||
if ("apiKey" in providerObj) {
|
||||
providerObj.apiKey = "***MASKED***";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configuration values (masks API keys)
|
||||
*/
|
||||
export async function cmdConfigList(storageRoot: string): Promise<unknown> {
|
||||
const configPath = getConfigPath(storageRoot);
|
||||
const config = loadConfig(configPath);
|
||||
const masked = maskApiKeys(config);
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration value
|
||||
*/
|
||||
export async function cmdConfigGet(storageRoot: string, key: string): Promise<unknown> {
|
||||
const configPath = getConfigPath(storageRoot);
|
||||
const config = loadConfig(configPath);
|
||||
const path = parseDotPath(key);
|
||||
const value = getNestedValue(config, path);
|
||||
|
||||
if (value === undefined) {
|
||||
throw new Error(`Key not found: ${key}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse value for args key (must be JSON array)
|
||||
*/
|
||||
function parseArgsValue(value: string): unknown {
|
||||
if (value.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Value must be an array");
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid JSON array for args key: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error("Value for 'args' key must be a JSON array starting with '['");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that we're not setting a property on a non-object
|
||||
*/
|
||||
function validateParentPath(
|
||||
config: Record<string, unknown>,
|
||||
path: string[],
|
||||
lastSegment: string,
|
||||
): void {
|
||||
if (path.length > 1) {
|
||||
const parentPath = path.slice(0, -1);
|
||||
const parent = getNestedValue(config, parentPath);
|
||||
if (parent !== null && parent !== undefined && typeof parent !== "object") {
|
||||
throw new Error(
|
||||
`Cannot set property '${lastSegment}' on non-object at path '${parentPath.join(".")}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a specific configuration value
|
||||
*/
|
||||
export async function cmdConfigSet(
|
||||
storageRoot: string,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<unknown> {
|
||||
const configPath = getConfigPath(storageRoot);
|
||||
|
||||
// Load existing config or create empty one
|
||||
let config: Record<string, unknown>;
|
||||
if (existsSync(configPath)) {
|
||||
config = loadConfig(configPath);
|
||||
} else {
|
||||
config = {};
|
||||
}
|
||||
|
||||
const path = parseDotPath(key);
|
||||
|
||||
// Validate the key path
|
||||
validateConfigKey(path);
|
||||
|
||||
const lastSegment = path[path.length - 1];
|
||||
|
||||
// Parse value if it's for an array key (args)
|
||||
let parsedValue: unknown = value;
|
||||
if (lastSegment === "args") {
|
||||
parsedValue = parseArgsValue(value);
|
||||
}
|
||||
|
||||
// Validate we're not setting a property on a non-object
|
||||
validateParentPath(config, path, lastSegment);
|
||||
|
||||
setNestedValue(config, path, parsedValue);
|
||||
saveConfig(configPath, config);
|
||||
|
||||
return { key, value: parsedValue };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
generateAdapterReference,
|
||||
generateAuthorReference,
|
||||
generateBootstrapReference,
|
||||
generateDeveloperReference,
|
||||
generateUserReference,
|
||||
} from "@uncaged/workflow-util";
|
||||
|
||||
export {
|
||||
generateAdapterReference as cmdPromptAdapter,
|
||||
generateAuthorReference as cmdPromptAuthor,
|
||||
generateBootstrapReference as cmdPromptBootstrap,
|
||||
generateDeveloperReference as cmdPromptDeveloper,
|
||||
generateUserReference as cmdPromptUser,
|
||||
};
|
||||
|
||||
const PROMPT_ENTRIES: ReadonlyArray<{ name: string; generate: () => string }> = [
|
||||
{ name: "user", generate: generateUserReference },
|
||||
{ name: "author", generate: generateAuthorReference },
|
||||
{ name: "developer", generate: generateDeveloperReference },
|
||||
{ name: "adapter", generate: generateAdapterReference },
|
||||
{ name: "bootstrap", generate: generateBootstrapReference },
|
||||
];
|
||||
|
||||
export function cmdPromptList(): ReadonlyArray<string> {
|
||||
return PROMPT_ENTRIES.map((e) => e.name);
|
||||
}
|
||||
|
||||
export function cmdPromptUsage(): string {
|
||||
return PROMPT_ENTRIES.map((e) => e.generate()).join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
export function cmdPromptSetup(): string {
|
||||
return `# uwf Skill Setup
|
||||
|
||||
You are being asked to install or update the uwf (United Workforce) skill
|
||||
so that you know how to use the \`uwf\` CLI for workflow orchestration.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Check if uwf CLI is installed:**
|
||||
\`\`\`bash
|
||||
uwf --help
|
||||
\`\`\`
|
||||
If not installed: \`bun add -g @uncaged/cli-workflow\`
|
||||
|
||||
2. **Clean up old versions of the skill** (if any exist):
|
||||
- Look for any existing uwf/workflow skill files in your skill directories
|
||||
(e.g. \`~/.hermes/skills/uwf/SKILL.md\` or similar paths).
|
||||
- If a skill exists, read its \`version\` from the YAML frontmatter.
|
||||
- If the version matches the current CLI version (\`uwf --version\`), you're done — no update needed.
|
||||
- If the version is older, **delete the old skill file** before proceeding.
|
||||
|
||||
3. **Get the usage skill content:**
|
||||
\`\`\`bash
|
||||
uwf prompt usage
|
||||
\`\`\`
|
||||
|
||||
4. **Create a SKILL.md file** in your skill directory (e.g. \`~/.hermes/skills/uwf/SKILL.md\`).
|
||||
The output of \`uwf prompt usage\` is the skill body WITHOUT frontmatter.
|
||||
You must prepend the following YAML frontmatter:
|
||||
|
||||
\`\`\`yaml
|
||||
---
|
||||
name: uwf
|
||||
description: "Use when orchestrating multi-agent workflows with uwf (United Workforce). Covers CLI usage, workflow YAML authoring, moderator logic, and agent development."
|
||||
version: <VERSION>
|
||||
author: United Workforce
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [workflow, agents, orchestration, cli, yaml]
|
||||
---
|
||||
\`\`\`
|
||||
|
||||
Replace \`<VERSION>\` with the installed CLI version (from \`uwf --version\`).
|
||||
|
||||
5. **Verify** the skill is loadable by your agent framework.
|
||||
|
||||
## Individual prompts
|
||||
|
||||
You can also get individual reference sections:
|
||||
|
||||
\`\`\`bash
|
||||
uwf prompt list # list available prompt names
|
||||
uwf prompt user # user reference (CLI guide + typical workflows)
|
||||
uwf prompt author # author reference (workflow YAML design guide)
|
||||
uwf prompt developer # developer reference (coding conventions + architecture)
|
||||
uwf prompt adapter # adapter reference (building agent adapters)
|
||||
uwf prompt bootstrap # bootstrap skill YAML for Hermes agents
|
||||
\`\`\`
|
||||
|
||||
## Notes
|
||||
|
||||
- The skill content is bundled with the CLI and versioned with it — always use
|
||||
\`uwf prompt usage\` to get the content matching your installed version.
|
||||
- Do NOT hand-edit the skill body. If the CLI is updated, re-run \`uwf prompt setup\`
|
||||
and follow the steps again.
|
||||
- When upgrading, always delete the old skill first to avoid stale instructions.
|
||||
`;
|
||||
}
|
||||
@@ -85,10 +85,6 @@ 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.
|
||||
*/
|
||||
@@ -106,37 +102,6 @@ function loadExistingConfig(configPath: string): Record<string, unknown> {
|
||||
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`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Extracted helpers — _discoverAgents
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -297,6 +262,80 @@ export function _printModelMenu(models: string[], termCols: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Agent selection prompt
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Known agent binary → display label mapping. */
|
||||
const KNOWN_AGENTS: Record<string, string> = {
|
||||
"uwf-hermes": "Hermes (hermes-agent)",
|
||||
"uwf-claude-code": "Claude Code",
|
||||
"uwf-cursor": "Cursor",
|
||||
"uwf-builtin": "Built-in (lightweight, no external agent)",
|
||||
};
|
||||
|
||||
/** Extract short agent name from binary name: uwf-claude-code → claude-code */
|
||||
export function _agentNameFromBinary(binary: string): string {
|
||||
return binary.replace(/^uwf-/, "");
|
||||
}
|
||||
|
||||
/** Prints numbered agent list to stdout. */
|
||||
export function _printAgentMenu(agents: string[]): void {
|
||||
const numWidth = String(agents.length).length;
|
||||
for (let i = 0; i < agents.length; i++) {
|
||||
const bin = agents[i] ?? "";
|
||||
const label = KNOWN_AGENTS[bin] ?? bin;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
console.log(` ${num}) ${label} (${bin})`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive agent selection. Discovers uwf-* binaries, lets user pick default.
|
||||
* Returns short agent name (e.g. "hermes", "claude-code").
|
||||
*/
|
||||
export async function _promptAgentSelection(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
): Promise<string> {
|
||||
console.log("Discovering installed agents...\n");
|
||||
const agents = await _discoverAgents();
|
||||
|
||||
if (agents.length === 0) {
|
||||
console.log(" No uwf-* agent binaries found in PATH.\n");
|
||||
console.log(" Install one first, for example:");
|
||||
console.log(" npm i -g @uncaged/workflow-agent-hermes");
|
||||
console.log(" npm i -g @uncaged/workflow-agent-claude-code\n");
|
||||
const manual = (
|
||||
await rl.question("Agent binary name (e.g. uwf-hermes), or press Enter to skip: ")
|
||||
).trim();
|
||||
if (!manual) return "hermes";
|
||||
return _agentNameFromBinary(manual.startsWith("uwf-") ? manual : `uwf-${manual}`);
|
||||
}
|
||||
|
||||
if (agents.length === 1) {
|
||||
const name = _agentNameFromBinary(agents[0] ?? "uwf-hermes");
|
||||
const label = KNOWN_AGENTS[agents[0] ?? ""] ?? agents[0];
|
||||
console.log(` Found 1 agent: ${label} — auto-selected.\n`);
|
||||
return name;
|
||||
}
|
||||
|
||||
console.log(` Found ${agents.length} agents:\n`);
|
||||
_printAgentMenu(agents);
|
||||
const choice = (await rl.question(`Choose default agent [1-${agents.length}]: `)).trim();
|
||||
const n = Number.parseInt(choice, 10);
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= agents.length) {
|
||||
const selected = agents[n - 1] ?? "uwf-hermes";
|
||||
const name = _agentNameFromBinary(selected);
|
||||
console.log(` → ${name}\n`);
|
||||
return name;
|
||||
}
|
||||
// Treat as literal name
|
||||
const name = _agentNameFromBinary(choice.startsWith("uwf-") ? choice : `uwf-${choice}`);
|
||||
console.log(` → ${name}\n`);
|
||||
return name;
|
||||
}
|
||||
|
||||
type ValidationResult = { ok: boolean; error: string | null };
|
||||
|
||||
/** Prints the model validation result to stdout. */
|
||||
@@ -323,8 +362,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const envName = apiKeyEnvName(args.provider);
|
||||
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
|
||||
providers[args.provider] = { baseUrl: args.baseUrl, apiKey: args.apiKey };
|
||||
|
||||
const models = (
|
||||
typeof existing.models === "object" && existing.models !== null
|
||||
@@ -339,9 +377,10 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const agentName = args.agent ?? "hermes";
|
||||
if (Object.keys(agents).length === 0) {
|
||||
agents.hermes = { command: "uwf-hermes", args: [] };
|
||||
const agentName = _agentNameFromBinary(args.agent ?? "hermes");
|
||||
// Ensure the selected agent has an entry
|
||||
if (!agents[agentName]) {
|
||||
agents[agentName] = { command: `uwf-${agentName}`, args: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -349,7 +388,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
|
||||
providers,
|
||||
models,
|
||||
agents,
|
||||
defaultAgent: existing.defaultAgent ?? agentName,
|
||||
defaultAgent: agentName,
|
||||
defaultModel: existing.defaultModel ?? "default",
|
||||
};
|
||||
}
|
||||
@@ -362,25 +401,17 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
|
||||
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);
|
||||
|
||||
// Validate model connectivity
|
||||
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
|
||||
|
||||
return {
|
||||
configPath,
|
||||
envPath,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
defaultAgent: merged.defaultAgent,
|
||||
@@ -543,11 +574,17 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
rl2.close();
|
||||
console.log(` → ${providerName}/${model}\n`);
|
||||
|
||||
// 4. Agent discovery & selection
|
||||
const rl3 = createInterface({ input, output });
|
||||
const agentName = await _promptAgentSelection(rl3);
|
||||
rl3.close();
|
||||
|
||||
const setupResult = await cmdSetup({
|
||||
provider: providerName,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
agent: agentName,
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
|
||||
import { getSchema } from "@uncaged/json-cas";
|
||||
import type { Store as CasStore, JSONSchema } from "@ocas/core";
|
||||
import { getSchema } from "@ocas/core";
|
||||
import type {
|
||||
CasRef,
|
||||
StartNodePayload,
|
||||
@@ -88,7 +88,7 @@ function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively expand all cas_ref fields in a CAS node's payload,
|
||||
* Recursively expand all ocas_ref fields in a CAS node's payload,
|
||||
* replacing hash strings with the referenced node's expanded payload.
|
||||
*/
|
||||
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
|
||||
@@ -120,7 +120,7 @@ function expandAnyOfField(
|
||||
): unknown {
|
||||
if (!Array.isArray(schema.anyOf)) return value;
|
||||
for (const sub of schema.anyOf as JSONSchema[]) {
|
||||
if (sub.format === "cas_ref" && typeof value === "string") {
|
||||
if (sub.format === "ocas_ref" && typeof value === "string") {
|
||||
return expandDeep(store, value as CasRef, visited);
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ function expandValue(
|
||||
value: unknown,
|
||||
visited: Set<string>,
|
||||
): unknown {
|
||||
if (schema.format === "cas_ref") return expandCasRefField(store, value, visited);
|
||||
if (schema.format === "ocas_ref") return expandCasRefField(store, value, visited);
|
||||
if (Array.isArray(schema.anyOf)) return expandAnyOfField(store, schema, value, visited);
|
||||
if (schema.type === "array") return expandArrayField(store, schema, value, visited);
|
||||
return expandObjectField(store, schema, value, visited);
|
||||
@@ -203,7 +203,7 @@ function collectOrderedSteps(
|
||||
|
||||
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const activeHead = index[threadId];
|
||||
const activeHead = index[threadId]?.head;
|
||||
if (activeHead !== undefined) {
|
||||
return activeHead;
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { generateCliReference as cmdSkillCli } from "@uncaged/workflow-util";
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BootstrapCapableStore } from "@uncaged/json-cas";
|
||||
import type { BootstrapCapableStore } from "@ocas/core";
|
||||
import type {
|
||||
CasRef,
|
||||
StartEntry,
|
||||
@@ -19,9 +19,16 @@ import {
|
||||
walkChain,
|
||||
} from "./shared.js";
|
||||
|
||||
type TurnToolCall = {
|
||||
name: string;
|
||||
args: string;
|
||||
};
|
||||
|
||||
type TurnData = {
|
||||
index: number;
|
||||
role: string;
|
||||
content: string;
|
||||
toolCalls: TurnToolCall[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -58,6 +65,7 @@ export async function cmdStepList(
|
||||
detail: item.payload.detail ?? null,
|
||||
agent: item.payload.agent,
|
||||
timestamp: item.timestamp,
|
||||
durationMs: item.payload.completedAtMs - item.payload.startedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,7 +113,7 @@ export async function cmdStepFork(
|
||||
|
||||
const newThreadId = generateUlid(Date.now()) as ThreadId;
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
index[newThreadId] = stepHash;
|
||||
index[newThreadId] = { head: stepHash, suspendedRole: null, suspendMessage: null };
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
return {
|
||||
@@ -127,8 +135,74 @@ function loadStepDetail(store: BootstrapCapableStore, detailRef: CasRef): Record
|
||||
return detailNode.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseTurnToolCalls(raw: unknown): TurnToolCall[] | null {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const calls: TurnToolCall[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== "object" || entry === null) {
|
||||
continue;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const name = record.name;
|
||||
const args = record.args;
|
||||
if (typeof name === "string") {
|
||||
calls.push({ name, args: typeof args === "string" ? args : "" });
|
||||
}
|
||||
}
|
||||
return calls.length > 0 ? calls : null;
|
||||
}
|
||||
|
||||
function formatTurnBody(turn: TurnData): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`**Turn role:** ${turn.role}`);
|
||||
|
||||
if (turn.toolCalls !== null) {
|
||||
for (const call of turn.toolCalls) {
|
||||
const argsSuffix = call.args !== "" ? ` — \`${call.args}\`` : "";
|
||||
parts.push(`- **${call.name}**${argsSuffix}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (turn.content !== "") {
|
||||
if (parts.length > 0) {
|
||||
parts.push("");
|
||||
}
|
||||
parts.push(turn.content);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function parseSingleTurn(
|
||||
store: BootstrapCapableStore,
|
||||
turnRef: unknown,
|
||||
fallbackIndex: number,
|
||||
): TurnData | null {
|
||||
if (typeof turnRef !== "string") {
|
||||
return null;
|
||||
}
|
||||
const turnNode = store.get(turnRef as CasRef);
|
||||
if (turnNode === null) {
|
||||
return null;
|
||||
}
|
||||
const turn = turnNode.payload as Record<string, unknown>;
|
||||
const content = typeof turn.content === "string" ? turn.content : "";
|
||||
const toolCalls = parseTurnToolCalls(turn.toolCalls);
|
||||
if (content === "" && toolCalls === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
index: typeof turn.index === "number" ? turn.index : fallbackIndex,
|
||||
role: typeof turn.role === "string" ? turn.role : "assistant",
|
||||
content,
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all turn nodes from CAS store and extract content
|
||||
* Load all turn nodes from CAS store and extract display fields
|
||||
*/
|
||||
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
@@ -137,19 +211,9 @@ function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[]
|
||||
|
||||
const turnData: TurnData[] = [];
|
||||
for (const turnRef of turns) {
|
||||
if (typeof turnRef !== "string") {
|
||||
continue;
|
||||
}
|
||||
const turnNode = store.get(turnRef as CasRef);
|
||||
if (turnNode === null) {
|
||||
continue;
|
||||
}
|
||||
const turn = turnNode.payload as Record<string, unknown>;
|
||||
if (typeof turn.content === "string") {
|
||||
turnData.push({
|
||||
index: typeof turn.index === "number" ? turn.index : turnData.length,
|
||||
content: turn.content,
|
||||
});
|
||||
const parsed = parseSingleTurn(store, turnRef, turnData.length);
|
||||
if (parsed !== null) {
|
||||
turnData.push(parsed);
|
||||
}
|
||||
}
|
||||
return turnData;
|
||||
@@ -167,7 +231,7 @@ function selectTurnsForQuota(turnData: TurnData[], availableQuota: number): Turn
|
||||
if (turn === undefined) continue;
|
||||
|
||||
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
||||
const turnBlock = turnHeader + turn.content;
|
||||
const turnBlock = turnHeader + formatTurnBody(turn);
|
||||
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
|
||||
const addCost = turnBlock.length + separatorCost;
|
||||
|
||||
@@ -212,7 +276,7 @@ function formatStepMarkdown(
|
||||
parts.push("");
|
||||
parts.push(`## Turn ${turn.index + 1}`);
|
||||
parts.push("");
|
||||
parts.push(turn.content);
|
||||
parts.push(formatTurnBody(turn));
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
@@ -225,6 +289,7 @@ export async function cmdStepRead(
|
||||
storageRoot: string,
|
||||
stepHash: CasRef,
|
||||
quota: number,
|
||||
showPrompt: boolean,
|
||||
): Promise<string> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const node = uwf.store.get(stepHash);
|
||||
@@ -236,6 +301,23 @@ export async function cmdStepRead(
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
|
||||
// --prompt mode: show the assembled prompt that was sent to the agent
|
||||
if (showPrompt) {
|
||||
const promptRef = (payload as Record<string, unknown>).assembledPrompt;
|
||||
if (typeof promptRef !== "string") {
|
||||
return `# Step ${stepHash}\n\n_Prompt not recorded (legacy step)._`;
|
||||
}
|
||||
const promptNode = uwf.store.get(promptRef as CasRef);
|
||||
if (promptNode === null) {
|
||||
return `# Step ${stepHash}\n\n_Prompt CAS node not found: ${promptRef}_`;
|
||||
}
|
||||
const promptText =
|
||||
typeof promptNode.payload === "string"
|
||||
? promptNode.payload
|
||||
: JSON.stringify(promptNode.payload);
|
||||
return `# Step ${stepHash}\n\n**Role:** ${payload.role}\n**Agent:** ${payload.agent}\n\n## Prompt\n\n${promptText}`;
|
||||
}
|
||||
|
||||
if (payload.detail === null) {
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
||||
import { validate } from "@uncaged/json-cas";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
|
||||
import { evaluate } from "@uncaged/workflow-moderator";
|
||||
import { validate } from "@ocas/core";
|
||||
import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
@@ -13,20 +11,31 @@ import type {
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
ThreadId,
|
||||
ThreadIndexEntry,
|
||||
ThreadListItem,
|
||||
ThreadStatus,
|
||||
ThreadsIndex,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
createThreadIndexEntry,
|
||||
markThreadSuspended,
|
||||
updateThreadHead,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
createProcessLogger,
|
||||
extractUlidTimestamp,
|
||||
generateUlid,
|
||||
type ProcessLogger,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type { AdapterOutput } from "@uncaged/workflow-util-agent";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { parse } from "yaml";
|
||||
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
|
||||
import { createIncludeTag } from "../include.js";
|
||||
import { evaluate, isSuspendResult } from "../moderator/index.js";
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
@@ -40,6 +49,7 @@ import {
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
|
||||
import { validateWorkflow } from "../validate-semantic.js";
|
||||
import {
|
||||
type ChainState,
|
||||
collectOrderedSteps,
|
||||
@@ -54,6 +64,135 @@ const END_ROLE = "$END";
|
||||
const START_ROLE = "$START";
|
||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||
|
||||
function buildStepOutputFromEvaluation(
|
||||
workflowHash: CasRef,
|
||||
threadId: ThreadId,
|
||||
head: CasRef,
|
||||
status: ThreadStatus,
|
||||
evaluation: ReturnType<typeof evaluate>,
|
||||
background: boolean | null,
|
||||
): StepOutput {
|
||||
const done = status === "completed";
|
||||
let currentRole: string | null = null;
|
||||
let suspendedRole: string | null = null;
|
||||
let suspendMessage: string | null = null;
|
||||
if (evaluation.ok) {
|
||||
if (isSuspendResult(evaluation.value)) {
|
||||
suspendedRole = evaluation.value.suspendedRole;
|
||||
suspendMessage = evaluation.value.prompt;
|
||||
} else if (evaluation.value.role !== END_ROLE) {
|
||||
currentRole = evaluation.value.role;
|
||||
}
|
||||
}
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head,
|
||||
status,
|
||||
currentRole,
|
||||
suspendedRole,
|
||||
suspendMessage,
|
||||
done,
|
||||
background,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSuspendFieldsFromGraph(
|
||||
uwf: UwfStore,
|
||||
head: CasRef,
|
||||
workflowRef: CasRef,
|
||||
): { suspendedRole: string | null; suspendMessage: string | null } {
|
||||
const chain = walkChain(uwf, head);
|
||||
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
|
||||
const workflow = loadWorkflowPayload(uwf, workflowRef);
|
||||
const result = evaluate(workflow.graph, lastRole, lastOutput);
|
||||
if (result.ok && isSuspendResult(result.value)) {
|
||||
return {
|
||||
suspendedRole: result.value.suspendedRole,
|
||||
suspendMessage: result.value.prompt,
|
||||
};
|
||||
}
|
||||
return { suspendedRole: null, suspendMessage: null };
|
||||
}
|
||||
|
||||
function resolveSuspendFieldsForShow(
|
||||
entry: ThreadIndexEntry,
|
||||
status: ThreadStatus,
|
||||
uwf: UwfStore,
|
||||
head: CasRef,
|
||||
workflowRef: CasRef,
|
||||
): { suspendedRole: string | null; suspendMessage: string | null } {
|
||||
if (status !== "suspended") {
|
||||
return { suspendedRole: null, suspendMessage: null };
|
||||
}
|
||||
if (entry.suspendedRole !== null && entry.suspendMessage !== null) {
|
||||
return { suspendedRole: entry.suspendedRole, suspendMessage: entry.suspendMessage };
|
||||
}
|
||||
const fromGraph = resolveSuspendFieldsFromGraph(uwf, head, workflowRef);
|
||||
return {
|
||||
suspendedRole: entry.suspendedRole ?? fromGraph.suspendedRole,
|
||||
suspendMessage: entry.suspendMessage ?? fromGraph.suspendMessage,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureThreadSuspendMetadata(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
entry: ThreadIndexEntry,
|
||||
suspendedRole: string,
|
||||
suspendMessage: string,
|
||||
): Promise<ThreadIndexEntry> {
|
||||
if (entry.suspendedRole !== null && entry.suspendMessage !== null) {
|
||||
return entry;
|
||||
}
|
||||
const updated = markThreadSuspended(entry, suspendedRole, suspendMessage);
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
index[threadId] = updated;
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function resolveActiveThreadStatus(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
uwf: UwfStore,
|
||||
head: CasRef,
|
||||
workflowRef: CasRef,
|
||||
): Promise<ThreadStatus> {
|
||||
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||
if (runningMarker !== null) {
|
||||
return "running";
|
||||
}
|
||||
|
||||
const chain = walkChain(uwf, head);
|
||||
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
|
||||
const workflow = loadWorkflowPayload(uwf, workflowRef);
|
||||
const result = evaluate(workflow.graph, lastRole, lastOutput);
|
||||
if (result.ok && isSuspendResult(result.value)) {
|
||||
return "suspended";
|
||||
}
|
||||
|
||||
return "idle";
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the current/next role from the workflow graph and chain state.
|
||||
* Returns null when the next role is $END, thread is suspended, or evaluation fails.
|
||||
*/
|
||||
function resolveCurrentRole(uwf: UwfStore, head: CasRef, workflowRef: CasRef): string | null {
|
||||
const chain = walkChain(uwf, head);
|
||||
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
|
||||
const workflow = loadWorkflowPayload(uwf, workflowRef);
|
||||
const result = evaluate(workflow.graph, lastRole, lastOutput);
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
if (isSuspendResult(result.value) || result.value.role === END_ROLE) {
|
||||
return null;
|
||||
}
|
||||
return result.value.role;
|
||||
}
|
||||
|
||||
const PL_THREAD_START = "7HNQ4B2X";
|
||||
const PL_MODERATOR = "M3K8V9T1";
|
||||
const PL_AGENT_SPAWN = "R5J2W8N4";
|
||||
@@ -61,6 +200,25 @@ const PL_AGENT_DONE = "C6P9E3H7";
|
||||
const PL_THREAD_ARCHIVED = "F4D8Q2K5";
|
||||
const PL_STEP_ERROR = "B8T5N1V6";
|
||||
const PL_BACKGROUND_START = "X7Q4W9M2";
|
||||
const PL_THREAD_RESUME = "K2R7M4N8";
|
||||
|
||||
type ResumeStepConfig = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
type AgentStepTarget = {
|
||||
role: string;
|
||||
edgePrompt: string;
|
||||
effectiveCwd: string;
|
||||
};
|
||||
|
||||
function buildResumePrompt(graphPrompt: string, supplement: string | null): string {
|
||||
if (supplement === null || supplement === "") {
|
||||
return graphPrompt;
|
||||
}
|
||||
return `${graphPrompt}\n\n${supplement}`;
|
||||
}
|
||||
|
||||
function failStep(plog: ProcessLogger, message: string): never {
|
||||
plog.log(PL_STEP_ERROR, message, null);
|
||||
@@ -100,6 +258,15 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
|
||||
return result;
|
||||
}
|
||||
}
|
||||
for (const indexName of ["index.yaml", "index.yml"]) {
|
||||
const candidate = resolvePath(dir, ".workflow", name, indexName);
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
/* not found */
|
||||
}
|
||||
}
|
||||
|
||||
// Check .workflows/ directory as fallback (legacy)
|
||||
for (const ext of [".yaml", ".yml"]) {
|
||||
@@ -108,6 +275,15 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
|
||||
return result;
|
||||
}
|
||||
}
|
||||
for (const indexName of ["index.yaml", "index.yml"]) {
|
||||
const candidate = resolvePath(dir, ".workflows", name, indexName);
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
/* not found */
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -154,7 +330,7 @@ async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promis
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parse(text) as unknown;
|
||||
raw = parse(text, { customTags: [createIncludeTag(dirname(filePath))] }) as unknown;
|
||||
} catch (e) {
|
||||
fail(`invalid YAML in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
@@ -169,6 +345,11 @@ async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promis
|
||||
fail(filenameError);
|
||||
}
|
||||
|
||||
const semanticErrors = validateWorkflow(payload);
|
||||
if (semanticErrors.length > 0) {
|
||||
fail(`workflow validation failed:\n${semanticErrors.map((e) => ` - ${e}`).join("\n")}`);
|
||||
}
|
||||
|
||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
|
||||
const stored = uwf.store.get(hash);
|
||||
@@ -260,7 +441,13 @@ export async function cmdThreadStart(
|
||||
workflowId: string,
|
||||
prompt: string,
|
||||
projectRoot: string,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<StartOutput> {
|
||||
// Validate cwd is an absolute path
|
||||
if (!isAbsolute(cwd)) {
|
||||
fail("cwd must be an absolute path");
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
|
||||
|
||||
@@ -272,6 +459,7 @@ export async function cmdThreadStart(
|
||||
const startPayload: StartNodePayload = {
|
||||
workflow: workflowHash,
|
||||
prompt,
|
||||
cwd,
|
||||
};
|
||||
|
||||
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||
@@ -281,7 +469,7 @@ export async function cmdThreadStart(
|
||||
}
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
index[threadId] = headHash;
|
||||
index[threadId] = createThreadIndexEntry(headHash);
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
plog.log(
|
||||
@@ -293,42 +481,80 @@ export async function cmdThreadStart(
|
||||
return { workflow: workflowHash, thread: threadId };
|
||||
}
|
||||
|
||||
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
): Promise<ThreadShowOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const activeHead = index[threadId];
|
||||
if (activeHead !== undefined) {
|
||||
const entry = index[threadId];
|
||||
if (entry !== undefined) {
|
||||
const activeHead = entry.head;
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflow = resolveWorkflowFromHead(uwf, activeHead);
|
||||
if (workflow === null) {
|
||||
fail(`failed to resolve workflow from head: ${activeHead}`);
|
||||
}
|
||||
|
||||
const status = await resolveActiveThreadStatus(
|
||||
storageRoot,
|
||||
threadId,
|
||||
uwf,
|
||||
activeHead,
|
||||
workflow,
|
||||
);
|
||||
const currentRole = resolveCurrentRole(uwf, activeHead, workflow);
|
||||
const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, activeHead, workflow);
|
||||
|
||||
const hint =
|
||||
status === "suspended"
|
||||
? `Thread is suspended. Resume with: uwf thread resume ${threadId}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
workflow,
|
||||
thread: threadId,
|
||||
head: activeHead,
|
||||
status,
|
||||
currentRole,
|
||||
suspendedRole: suspendFields.suspendedRole,
|
||||
suspendMessage: suspendFields.suspendMessage,
|
||||
done: false,
|
||||
background: null,
|
||||
hint,
|
||||
};
|
||||
}
|
||||
|
||||
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||
if (hist !== null) {
|
||||
const status: ThreadStatus = hist.reason === "cancelled" ? "cancelled" : "completed";
|
||||
|
||||
return {
|
||||
workflow: hist.workflow,
|
||||
thread: threadId,
|
||||
head: hist.head,
|
||||
status,
|
||||
currentRole: null,
|
||||
suspendedRole: null,
|
||||
suspendMessage: null,
|
||||
done: true,
|
||||
background: null,
|
||||
hint: null,
|
||||
};
|
||||
}
|
||||
|
||||
fail(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
export type ThreadStatus = "idle" | "running" | "completed";
|
||||
|
||||
export type ThreadListItemWithStatus = ThreadListItem & {
|
||||
status: ThreadStatus;
|
||||
currentRole: string | null;
|
||||
/** Display label with status marker for suspended threads */
|
||||
statusDisplay: string;
|
||||
};
|
||||
|
||||
export type ThreadShowOutput = StepOutput & {
|
||||
/** Hint message for suspended threads */
|
||||
hint: string | null;
|
||||
};
|
||||
|
||||
async function threadListItemFromActive(
|
||||
@@ -342,11 +568,17 @@ async function threadListItemFromActive(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if thread is currently running in background
|
||||
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
|
||||
const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, head, workflow);
|
||||
const statusDisplay = status === "suspended" ? `${status} [suspended]` : status;
|
||||
|
||||
return { thread: threadId, workflow, head, status };
|
||||
return {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
status,
|
||||
currentRole: resolveCurrentRole(uwf, head, workflow),
|
||||
statusDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectActiveThreads(
|
||||
@@ -355,13 +587,8 @@ async function collectActiveThreads(
|
||||
index: ThreadsIndex,
|
||||
): Promise<ThreadListItemWithStatus[]> {
|
||||
const items: ThreadListItemWithStatus[] = [];
|
||||
for (const [threadId, head] of Object.entries(index)) {
|
||||
const item = await threadListItemFromActive(
|
||||
storageRoot,
|
||||
uwf,
|
||||
threadId as ThreadId,
|
||||
head as CasRef,
|
||||
);
|
||||
for (const [threadId, entry] of Object.entries(index)) {
|
||||
const item = await threadListItemFromActive(storageRoot, uwf, threadId as ThreadId, entry.head);
|
||||
if (item !== null) {
|
||||
items.push(item);
|
||||
}
|
||||
@@ -379,11 +606,14 @@ async function collectCompletedThreads(
|
||||
for (const entry of history) {
|
||||
if (!activeIds.has(entry.thread) && !seen.has(entry.thread)) {
|
||||
seen.add(entry.thread);
|
||||
const status = entry.reason === "cancelled" ? "cancelled" : "completed";
|
||||
items.push({
|
||||
thread: entry.thread,
|
||||
workflow: entry.workflow,
|
||||
head: entry.head,
|
||||
status: "completed",
|
||||
status,
|
||||
currentRole: null,
|
||||
statusDisplay: status,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -438,7 +668,10 @@ export async function cmdThreadList(
|
||||
let items = await collectActiveThreads(storageRoot, uwf, index);
|
||||
|
||||
// Collect completed threads (if relevant for status filter)
|
||||
const includeCompleted = statusFilter === null || statusFilter.includes("completed");
|
||||
const includeCompleted =
|
||||
statusFilter === null ||
|
||||
statusFilter.includes("completed") ||
|
||||
statusFilter.includes("cancelled");
|
||||
if (includeCompleted) {
|
||||
const activeIds = new Set(items.map((i) => i.thread));
|
||||
const completedItems = await collectCompletedThreads(storageRoot, activeIds);
|
||||
@@ -560,14 +793,25 @@ function selectByQuota(
|
||||
return { selected, skippedCount: candidates.length - selected.length };
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSec = Math.round(seconds % 60);
|
||||
return `${minutes}m${remainingSec}s`;
|
||||
}
|
||||
|
||||
function formatStepHeader(stepNum: number, item: OrderedStepItem): string {
|
||||
const ts = new Date(item.timestamp)
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d+Z$/, "");
|
||||
const durationMs = item.payload.completedAtMs - item.payload.startedAtMs;
|
||||
const duration = formatDuration(durationMs);
|
||||
return [
|
||||
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts} | **Duration:** ${duration}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -669,14 +913,16 @@ function formatThreadReadMarkdown(options: {
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
type EvaluateLastOutput = Record<string, unknown> & { status: string };
|
||||
type EvaluateLastOutput = Record<string, unknown>;
|
||||
|
||||
const STATUS_KEY = "$status";
|
||||
|
||||
function resolveEvaluateArgs(
|
||||
uwf: UwfStore,
|
||||
chain: ChainState,
|
||||
): { lastRole: string; lastOutput: EvaluateLastOutput } {
|
||||
if (chain.headIsStart) {
|
||||
return { lastRole: START_ROLE, lastOutput: { status: "_" } };
|
||||
return { lastRole: START_ROLE, lastOutput: { [STATUS_KEY]: "_" } };
|
||||
}
|
||||
|
||||
const lastStep = chain.stepsNewestFirst[0];
|
||||
@@ -689,11 +935,10 @@ function resolveEvaluateArgs(
|
||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
const status = typeof base.status === "string" ? base.status : "_";
|
||||
|
||||
return {
|
||||
lastRole: lastStep.role,
|
||||
lastOutput: { ...base, status },
|
||||
lastOutput: base,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -751,7 +996,8 @@ function spawnAgent(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
): CasRef {
|
||||
cwd: string,
|
||||
): AdapterOutput {
|
||||
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
|
||||
let stdout: string;
|
||||
try {
|
||||
@@ -759,6 +1005,7 @@ function spawnAgent(
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
|
||||
cwd,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
|
||||
@@ -773,10 +1020,22 @@ function spawnAgent(
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!isCasRef(line)) {
|
||||
failStep(plog, `agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
failStep(plog, `agent stdout last line is not valid JSON: ${line || "(empty)"}`);
|
||||
}
|
||||
return line;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (
|
||||
typeof obj !== "object" ||
|
||||
obj === null ||
|
||||
typeof obj.stepHash !== "string" ||
|
||||
!isCasRef(obj.stepHash as string)
|
||||
) {
|
||||
failStep(plog, `agent stdout JSON missing valid stepHash: ${line}`);
|
||||
}
|
||||
return obj as unknown as AdapterOutput;
|
||||
}
|
||||
|
||||
async function archiveThread(
|
||||
@@ -793,6 +1052,66 @@ async function archiveThread(
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdThreadResume(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
supplement: string | null,
|
||||
agentOverride: string | null,
|
||||
): Promise<StepOutput> {
|
||||
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||
if (runningMarker !== null) {
|
||||
fail(`thread already executing in background (PID: ${runningMarker.pid})`);
|
||||
}
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const entry = index[threadId];
|
||||
if (entry === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const headHash = entry.head;
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflowHash = chain.start.workflow;
|
||||
|
||||
const status = await resolveActiveThreadStatus(
|
||||
storageRoot,
|
||||
threadId,
|
||||
uwf,
|
||||
headHash,
|
||||
workflowHash,
|
||||
);
|
||||
if (status !== "suspended") {
|
||||
fail(`thread is not suspended: ${threadId} (status: ${status})`);
|
||||
}
|
||||
|
||||
const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, headHash, workflowHash);
|
||||
if (suspendFields.suspendedRole === null) {
|
||||
fail(`thread is suspended but suspendedRole is missing: ${threadId}`);
|
||||
}
|
||||
if (suspendFields.suspendMessage === null) {
|
||||
fail(`thread is suspended but suspendMessage is missing: ${threadId}`);
|
||||
}
|
||||
|
||||
const resumePrompt = buildResumePrompt(suspendFields.suspendMessage, supplement);
|
||||
const plog = createProcessLogger({
|
||||
storageRoot,
|
||||
context: { thread: threadId, workflow: workflowHash },
|
||||
});
|
||||
|
||||
plog.log(
|
||||
PL_THREAD_RESUME,
|
||||
`resume role=${suspendFields.suspendedRole} supplement=${supplement !== null}`,
|
||||
null,
|
||||
);
|
||||
|
||||
return cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog, {
|
||||
role: suspendFields.suspendedRole,
|
||||
prompt: resumePrompt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -844,7 +1163,7 @@ export async function cmdThreadExec(
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog);
|
||||
results.push(result);
|
||||
if (result.done) {
|
||||
if (result.done || result.status === "suspended") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -862,12 +1181,12 @@ async function resolveActiveThreadWorkflowHash(
|
||||
threadId: ThreadId,
|
||||
): Promise<CasRef> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
if (headHash === undefined) {
|
||||
const entry = index[threadId];
|
||||
if (entry === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const chain = walkChain(uwf, entry.head);
|
||||
return chain.start.workflow;
|
||||
}
|
||||
|
||||
@@ -881,10 +1200,13 @@ async function cmdThreadStepBackground(
|
||||
): Promise<StepOutput[]> {
|
||||
// Get current head to return to caller
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
if (headHash === undefined) {
|
||||
const entry = index[threadId];
|
||||
if (entry === undefined) {
|
||||
failStep(plog, `thread not active: ${threadId}`);
|
||||
}
|
||||
const headHash = entry.head;
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
|
||||
// Spawn detached background process
|
||||
const scriptPath = process.argv[1];
|
||||
@@ -916,30 +1238,44 @@ async function cmdThreadStepBackground(
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
status: "running",
|
||||
currentRole: resolveCurrentRole(uwf, headHash, workflowHash),
|
||||
suspendedRole: null,
|
||||
suspendMessage: null,
|
||||
done: false,
|
||||
background: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function cmdThreadStepOnce(
|
||||
function resolveResumeStepTarget(
|
||||
resume: ResumeStepConfig,
|
||||
chain: ChainState,
|
||||
threadCwd: string,
|
||||
plog: ProcessLogger,
|
||||
): AgentStepTarget {
|
||||
const lastStep = chain.stepsNewestFirst[0];
|
||||
plog.log(PL_MODERATOR, `resume role=${resume.role} prompt=${resume.prompt}`, null);
|
||||
return {
|
||||
role: resume.role,
|
||||
edgePrompt: resume.prompt,
|
||||
effectiveCwd: lastStep !== undefined && lastStep.cwd !== "" ? lastStep.cwd : threadCwd,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveModeratorStepTarget(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
entry: ThreadIndexEntry,
|
||||
headHash: CasRef,
|
||||
workflowHash: CasRef,
|
||||
workflow: WorkflowPayload,
|
||||
uwf: UwfStore,
|
||||
chain: ChainState,
|
||||
threadCwd: string,
|
||||
plog: ProcessLogger,
|
||||
): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
if (headHash === undefined) {
|
||||
failStep(plog, `thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflowHash = chain.start.workflow;
|
||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||
): Promise<StepOutput | AgentStepTarget> {
|
||||
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
|
||||
|
||||
const nextResult = evaluate(workflow.graph, lastRole, lastOutput);
|
||||
if (!nextResult.ok) {
|
||||
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
|
||||
@@ -947,10 +1283,32 @@ async function cmdThreadStepOnce(
|
||||
|
||||
plog.log(
|
||||
PL_MODERATOR,
|
||||
`moderator role=${nextResult.value.role} prompt=${nextResult.value.prompt}`,
|
||||
`moderator ${
|
||||
isSuspendResult(nextResult.value)
|
||||
? `action=suspend suspendedRole=${nextResult.value.suspendedRole}`
|
||||
: `role=${nextResult.value.role}`
|
||||
} prompt=${nextResult.value.prompt}`,
|
||||
null,
|
||||
);
|
||||
|
||||
if (isSuspendResult(nextResult.value)) {
|
||||
await ensureThreadSuspendMetadata(
|
||||
storageRoot,
|
||||
threadId,
|
||||
entry,
|
||||
nextResult.value.suspendedRole,
|
||||
nextResult.value.prompt,
|
||||
);
|
||||
return buildStepOutputFromEvaluation(
|
||||
workflowHash,
|
||||
threadId,
|
||||
headHash,
|
||||
"suspended",
|
||||
nextResult,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (nextResult.value.role === END_ROLE) {
|
||||
plog.log(PL_THREAD_ARCHIVED, `thread archived head=${headHash}`, null);
|
||||
await archiveThread(storageRoot, threadId, workflowHash, headHash);
|
||||
@@ -958,35 +1316,34 @@ async function cmdThreadStepOnce(
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
status: "completed",
|
||||
currentRole: null,
|
||||
suspendedRole: null,
|
||||
suspendMessage: null,
|
||||
done: true,
|
||||
background: null,
|
||||
};
|
||||
}
|
||||
|
||||
const role = nextResult.value.role;
|
||||
const edgePrompt = nextResult.value.prompt;
|
||||
const config = await loadWorkflowConfig(storageRoot);
|
||||
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||
return {
|
||||
role: nextResult.value.role,
|
||||
edgePrompt: nextResult.value.prompt,
|
||||
effectiveCwd: nextResult.value.location !== null ? nextResult.value.location : threadCwd,
|
||||
};
|
||||
}
|
||||
|
||||
plog.log(PL_AGENT_SPAWN, `spawning agent command=${agent.command}`, {
|
||||
args: [...agent.args, threadId, role].join(" "),
|
||||
});
|
||||
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt);
|
||||
|
||||
plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
|
||||
|
||||
// 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) {
|
||||
failStep(plog, `agent returned hash that is not a StepNode: ${newHead}`);
|
||||
}
|
||||
|
||||
// Reload threads index to avoid overwriting changes made by the agent subprocess
|
||||
async function finalizeAgentStep(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
workflowHash: CasRef,
|
||||
workflow: WorkflowPayload,
|
||||
newHead: CasRef,
|
||||
uwfAfter: UwfStore,
|
||||
plog: ProcessLogger,
|
||||
): Promise<StepOutput> {
|
||||
const freshIndex = await loadThreadsIndex(storageRoot);
|
||||
freshIndex[threadId] = newHead;
|
||||
const priorEntry = freshIndex[threadId] ?? createThreadIndexEntry(newHead);
|
||||
freshIndex[threadId] = updateThreadHead(priorEntry, newHead);
|
||||
await saveThreadsIndex(storageRoot, freshIndex);
|
||||
|
||||
const chainAfter = walkChain(uwfAfter, newHead);
|
||||
@@ -999,24 +1356,112 @@ async function cmdThreadStepOnce(
|
||||
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
|
||||
}
|
||||
|
||||
if (isSuspendResult(afterResult.value)) {
|
||||
freshIndex[threadId] = markThreadSuspended(
|
||||
freshIndex[threadId] ?? createThreadIndexEntry(newHead),
|
||||
afterResult.value.suspendedRole,
|
||||
afterResult.value.prompt,
|
||||
);
|
||||
await saveThreadsIndex(storageRoot, freshIndex);
|
||||
return buildStepOutputFromEvaluation(
|
||||
workflowHash,
|
||||
threadId,
|
||||
newHead,
|
||||
"suspended",
|
||||
afterResult,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
const done = afterResult.value.role === END_ROLE;
|
||||
if (done) {
|
||||
plog.log(PL_THREAD_ARCHIVED, `thread archived head=${newHead}`, null);
|
||||
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||
}
|
||||
|
||||
const status: ThreadStatus = done ? "completed" : "idle";
|
||||
const currentRole = done ? null : afterResult.value.role;
|
||||
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: newHead,
|
||||
status,
|
||||
currentRole,
|
||||
suspendedRole: null,
|
||||
suspendMessage: null,
|
||||
done,
|
||||
background: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function cmdThreadStepOnce(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
plog: ProcessLogger,
|
||||
resume: ResumeStepConfig | null = null,
|
||||
): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const entry = index[threadId];
|
||||
if (entry === undefined) {
|
||||
failStep(plog, `thread not active: ${threadId}`);
|
||||
}
|
||||
const headHash = entry.head;
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflowHash = chain.start.workflow;
|
||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||
const threadCwd = chain.start.cwd;
|
||||
|
||||
const targetOrOutput =
|
||||
resume !== null
|
||||
? resolveResumeStepTarget(resume, chain, threadCwd, plog)
|
||||
: await resolveModeratorStepTarget(
|
||||
storageRoot,
|
||||
threadId,
|
||||
entry,
|
||||
headHash,
|
||||
workflowHash,
|
||||
workflow,
|
||||
uwf,
|
||||
chain,
|
||||
threadCwd,
|
||||
plog,
|
||||
);
|
||||
|
||||
if ("status" in targetOrOutput) {
|
||||
return targetOrOutput;
|
||||
}
|
||||
|
||||
const { role, edgePrompt, effectiveCwd } = targetOrOutput;
|
||||
|
||||
const config = await loadWorkflowConfig(storageRoot);
|
||||
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||
|
||||
plog.log(PL_AGENT_SPAWN, `spawning agent command=${agent.command}`, {
|
||||
args: [...agent.args, threadId, role].join(" "),
|
||||
});
|
||||
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
|
||||
const newHead = agentResult.stepHash as CasRef;
|
||||
|
||||
plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
|
||||
|
||||
const uwfAfter = await createUwfStore(storageRoot);
|
||||
const newNode = uwfAfter.store.get(newHead);
|
||||
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
|
||||
failStep(plog, `agent returned hash that is not a StepNode: ${newHead}`);
|
||||
}
|
||||
|
||||
return finalizeAgentStep(storageRoot, threadId, workflowHash, workflow, newHead, uwfAfter, plog);
|
||||
}
|
||||
|
||||
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const activeHead = index[threadId];
|
||||
const activeHead = index[threadId]?.head;
|
||||
if (activeHead !== undefined) {
|
||||
return activeHead;
|
||||
}
|
||||
@@ -1069,8 +1514,8 @@ export type CancelOutput = {
|
||||
*/
|
||||
export async function cmdThreadStop(storageRoot: string, threadId: ThreadId): Promise<StopOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
if (head === undefined) {
|
||||
const entry = index[threadId];
|
||||
if (entry === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
@@ -1099,10 +1544,11 @@ export async function cmdThreadCancel(
|
||||
threadId: ThreadId,
|
||||
): Promise<CancelOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
if (head === undefined) {
|
||||
const entry = index[threadId];
|
||||
if (entry === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
const head = entry.head;
|
||||
|
||||
// Check if thread is running in background and terminate it
|
||||
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||
@@ -1129,6 +1575,7 @@ export async function cmdThreadCancel(
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "cancelled",
|
||||
};
|
||||
await appendThreadHistory(storageRoot, historyEntry);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve as resolvePath } from "node:path";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type { JSONSchema } from "@ocas/core";
|
||||
import { putSchema, validate } from "@ocas/core";
|
||||
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
import { createIncludeTag } from "../include.js";
|
||||
|
||||
import {
|
||||
createUwfStore,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
|
||||
import { validateWorkflow } from "../validate-semantic.js";
|
||||
|
||||
export type WorkflowOrigin = "local" | "global";
|
||||
|
||||
@@ -60,6 +63,7 @@ function normalizeGraph(
|
||||
normalized[status] = {
|
||||
role: target.role,
|
||||
prompt: target.prompt,
|
||||
location: target.location ?? null,
|
||||
};
|
||||
}
|
||||
result[node] = normalized;
|
||||
@@ -121,7 +125,9 @@ export async function cmdWorkflowAdd(
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parse(text) as unknown;
|
||||
raw = parse(text, {
|
||||
customTags: [createIncludeTag(dirname(resolvePath(filePath)))],
|
||||
}) as unknown;
|
||||
} catch (e) {
|
||||
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
@@ -136,6 +142,11 @@ export async function cmdWorkflowAdd(
|
||||
fail(filenameError);
|
||||
}
|
||||
|
||||
const semanticErrors = validateWorkflow(payload);
|
||||
if (semanticErrors.length > 0) {
|
||||
fail(`workflow validation failed:\n${semanticErrors.map((e) => ` - ${e}`).join("\n")}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, extname, resolve } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
/**
|
||||
* Create a YAML customTags entry for !include that resolves file paths
|
||||
* relative to the given base directory.
|
||||
*
|
||||
* Security: resolved paths must stay within baseDir (path traversal prevention).
|
||||
* Nested !include in .yaml/.yml files is supported (customTags passed recursively).
|
||||
*/
|
||||
export function createIncludeTag(baseDir: string) {
|
||||
const resolvedBase = resolve(baseDir);
|
||||
return {
|
||||
tag: "!include",
|
||||
resolve(str: string) {
|
||||
const filePath = resolve(resolvedBase, str);
|
||||
// Path traversal guard: resolved path must be inside baseDir
|
||||
if (!filePath.startsWith(`${resolvedBase}/`) && filePath !== resolvedBase) {
|
||||
throw new Error(
|
||||
`!include path traversal blocked: "${str}" resolves outside base directory`,
|
||||
);
|
||||
}
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
if (ext === ".json") {
|
||||
return JSON.parse(content);
|
||||
}
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
// Pass customTags recursively so nested !include works,
|
||||
// scoped to the included file's directory
|
||||
return parseYaml(content, { customTags: [createIncludeTag(dirname(filePath))] });
|
||||
}
|
||||
return content;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { evaluate } from "../evaluate.js";
|
||||
import { isSuspendResult } from "../types.js";
|
||||
|
||||
describe("Edge prompt template variable resolution", () => {
|
||||
test("returns error when rendered prompt is empty string", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain("prompt");
|
||||
expect(result.error.message).toContain("empty");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns error when rendered prompt is whitespace-only", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: " {{{userPrompt}}} ", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain("prompt");
|
||||
expect(result.error.message).toContain("empty");
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds when all template variables resolve to non-empty values", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", { userPrompt: "Fix the bug" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.prompt).toBe("Fix the bug");
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds with static (no-variable) prompt", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "Classify this input", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.prompt).toBe("Classify this input");
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds when prompt has mix of static text and unresolved variables", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "Please handle: {{{userPrompt}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.prompt).toBe("Please handle: ");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns error when ALL variables missing and no static text remains", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "{{{a}}}{{{b}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Moderator location resolution", () => {
|
||||
test("returns null location when edge has no location field", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", { $status: "ready" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok && !isSuspendResult(result.value)) {
|
||||
expect(result.value.location).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves static location string", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "/static/path",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", { $status: "ready" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok && !isSuspendResult(result.value)) {
|
||||
expect(result.value.location).toBe("/static/path");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves mustache template location", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{repoPath}}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", {
|
||||
$status: "ready",
|
||||
repoPath: "/home/user/repo",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok && !isSuspendResult(result.value)) {
|
||||
expect(result.value.location).toBe("/home/user/repo");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves mustache template with multiple variables", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{basePath}}}/{{{projectName}}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", {
|
||||
$status: "ready",
|
||||
basePath: "/home/user",
|
||||
projectName: "myproject",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok && !isSuspendResult(result.value)) {
|
||||
expect(result.value.location).toBe("/home/user/myproject");
|
||||
}
|
||||
});
|
||||
|
||||
test("handles missing template variable gracefully", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{repoPath}}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", { $status: "ready" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok && !isSuspendResult(result.value)) {
|
||||
// Mustache renders missing variables as empty string
|
||||
expect(result.value.location).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Target } from "@uncaged/workflow-protocol";
|
||||
import mustache from "mustache";
|
||||
|
||||
import type { EvaluateResult, Result } from "./types.js";
|
||||
|
||||
// Disable HTML escaping — prompts are plain text, not HTML.
|
||||
mustache.escape = (text: string) => text;
|
||||
|
||||
const START_ROLE = "$START";
|
||||
const SUSPEND_ROLE = "$SUSPEND";
|
||||
const UNIT_STATUS = "_";
|
||||
|
||||
type LastOutput = Record<string, unknown>;
|
||||
|
||||
const STATUS_KEY = "$status";
|
||||
|
||||
export function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: LastOutput,
|
||||
): Result<EvaluateResult, Error> {
|
||||
const status =
|
||||
lastRole === START_ROLE
|
||||
? UNIT_STATUS
|
||||
: typeof lastOutput[STATUS_KEY] === "string"
|
||||
? (lastOutput[STATUS_KEY] as string)
|
||||
: UNIT_STATUS;
|
||||
|
||||
const roleTargets = graph[lastRole];
|
||||
if (roleTargets === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${lastRole}"`),
|
||||
};
|
||||
}
|
||||
|
||||
const target = roleTargets[status];
|
||||
if (target === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = mustache.render(target.prompt, lastOutput);
|
||||
if (prompt.trim() === "") {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`edge prompt resolved to empty string for role "${target.role}" (template: "${target.prompt}"). Check that upstream output includes required variables.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (target.role === SUSPEND_ROLE) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
action: "suspend",
|
||||
suspendedRole: lastRole,
|
||||
prompt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const location = target.location !== null ? mustache.render(target.location, lastOutput) : null;
|
||||
return { ok: true, value: { role: target.role, prompt, location } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { evaluate } from "./evaluate.js";
|
||||
export type {
|
||||
EvaluateResult,
|
||||
EvaluateRouteResult,
|
||||
EvaluateSuspendResult,
|
||||
} from "./types.js";
|
||||
export { isSuspendResult } from "./types.js";
|
||||
@@ -0,0 +1,24 @@
|
||||
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
/** Moderator routes the thread to a real role (or `$END`). */
|
||||
export type EvaluateRouteResult = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
/** Resolved working directory from edge location field (null = inherit thread cwd). */
|
||||
location: string | null;
|
||||
};
|
||||
|
||||
/** Moderator routes the thread to `$SUSPEND` — waiting for external input. */
|
||||
export type EvaluateSuspendResult = {
|
||||
action: "suspend";
|
||||
/** Role whose output triggered the suspend transition. */
|
||||
suspendedRole: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
/** The result of moderator evaluation. */
|
||||
export type EvaluateResult = EvaluateRouteResult | EvaluateSuspendResult;
|
||||
|
||||
export function isSuspendResult(result: EvaluateResult): result is EvaluateSuspendResult {
|
||||
return "action" in result && result.action === "suspend";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
|
||||
|
||||
export const TEXT_SCHEMA = { type: "string" as const };
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { access, appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { BootstrapCapableStore, Hash } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/workflow-protocol";
|
||||
import type { BootstrapCapableStore, Hash } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type {
|
||||
CasRef,
|
||||
ThreadId,
|
||||
ThreadIndexEntry,
|
||||
ThreadListItem,
|
||||
ThreadsIndex,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
createThreadIndexEntry,
|
||||
parseThreadsIndex,
|
||||
serializeThreadsIndex,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
|
||||
@@ -19,17 +31,38 @@ export type ProjectWorkflowEntry = {
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
/** Extract workflow name from a YAML filename (strip .yaml/.yml extension). */
|
||||
function stemFromYaml(name: string): string {
|
||||
if (name.endsWith(".yaml")) return name.slice(0, -5);
|
||||
if (name.endsWith(".yml")) return name.slice(0, -4);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Check if a directory contains an index.yaml or index.yml workflow file. */
|
||||
async function findIndexWorkflow(
|
||||
dir: string,
|
||||
dirName: string,
|
||||
): Promise<ProjectWorkflowEntry | null> {
|
||||
for (const indexName of ["index.yaml", "index.yml"]) {
|
||||
const indexPath = join(dir, dirName, indexName);
|
||||
try {
|
||||
await access(indexPath);
|
||||
return { name: dirName, filePath: indexPath };
|
||||
} catch {
|
||||
// not found, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `<projectRoot>/.workflows/*.yaml` (non-recursive) and return discovered entries.
|
||||
* Returns an empty array if the directory does not exist.
|
||||
* Scan a single directory for workflow entries (flat YAML files + folder/index.yaml).
|
||||
* Returns discovered entries. Returns empty array if directory does not exist.
|
||||
*/
|
||||
export async function discoverProjectWorkflows(
|
||||
projectRoot: string,
|
||||
): Promise<ProjectWorkflowEntry[]> {
|
||||
const dir = join(projectRoot, ".workflows");
|
||||
let entries: string[];
|
||||
async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
|
||||
let dirents: Dirent[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
dirents = await readdir(dir, { withFileTypes: true });
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
||||
@@ -39,16 +72,39 @@ export async function discoverProjectWorkflows(
|
||||
}
|
||||
|
||||
const result: ProjectWorkflowEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".yaml") && !entry.endsWith(".yml")) {
|
||||
continue;
|
||||
for (const entry of dirents) {
|
||||
if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) {
|
||||
result.push({ name: stemFromYaml(entry.name), filePath: join(dir, entry.name) });
|
||||
} else if (entry.isDirectory()) {
|
||||
const found = await findIndexWorkflow(dir, entry.name);
|
||||
if (found !== null) {
|
||||
result.push(found);
|
||||
}
|
||||
}
|
||||
const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4);
|
||||
result.push({ name: stem, filePath: join(dir, entry) });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `<projectRoot>/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries.
|
||||
* .workflow/ takes priority: if a name is found in both, .workflow/ wins.
|
||||
* Returns an empty array if neither directory exists.
|
||||
*/
|
||||
export async function discoverProjectWorkflows(
|
||||
projectRoot: string,
|
||||
): Promise<ProjectWorkflowEntry[]> {
|
||||
const primary = await scanWorkflowDir(join(projectRoot, ".workflow"));
|
||||
const legacy = await scanWorkflowDir(join(projectRoot, ".workflows"));
|
||||
const seen = new Set(primary.map((e) => e.name));
|
||||
const merged = [...primary];
|
||||
for (const entry of legacy) {
|
||||
if (!seen.has(entry.name)) {
|
||||
merged.push(entry);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||
export function getDefaultStorageRoot(): string {
|
||||
return join(homedir(), ".uncaged", "workflow");
|
||||
@@ -70,10 +126,26 @@ export function resolveStorageRoot(): string {
|
||||
return getDefaultStorageRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated: Use `getGlobalCasDir()` instead.
|
||||
* Returns the old CAS directory for backward compatibility.
|
||||
*/
|
||||
export function getCasDir(storageRoot: string): string {
|
||||
return join(storageRoot, "cas");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the global CAS directory shared by all uwf and json-cas tools.
|
||||
* Priority: UNCAGED_CAS_DIR environment variable → default ~/.uncaged/json-cas
|
||||
*/
|
||||
export function getGlobalCasDir(): string {
|
||||
const envPath = process.env.UNCAGED_CAS_DIR;
|
||||
if (envPath !== undefined && envPath !== "") {
|
||||
return envPath;
|
||||
}
|
||||
return join(homedir(), ".uncaged", "json-cas");
|
||||
}
|
||||
|
||||
export function getRegistryPath(storageRoot: string): string {
|
||||
return join(storageRoot, "workflows.yaml");
|
||||
}
|
||||
@@ -88,6 +160,7 @@ export function getHistoryPath(storageRoot: string): string {
|
||||
|
||||
export type ThreadHistoryLine = ThreadListItem & {
|
||||
completedAt: number;
|
||||
reason: "completed" | "cancelled" | null;
|
||||
};
|
||||
|
||||
export type UwfStore = {
|
||||
@@ -97,7 +170,7 @@ export type UwfStore = {
|
||||
};
|
||||
|
||||
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = getCasDir(storageRoot);
|
||||
const casDir = getGlobalCasDir();
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
@@ -172,16 +245,7 @@ export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsInde
|
||||
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;
|
||||
return parseThreadsIndex(raw);
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
@@ -191,10 +255,25 @@ export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsInde
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveThreadsIndex(storageRoot: string, index: ThreadsIndex): Promise<void> {
|
||||
/** Accept legacy CasRef values for test convenience. */
|
||||
export type ThreadsIndexInput = Record<ThreadId, ThreadIndexEntry | CasRef>;
|
||||
|
||||
function normalizeThreadsIndexInput(index: ThreadsIndexInput): ThreadsIndex {
|
||||
const normalized: ThreadsIndex = {};
|
||||
for (const [threadId, value] of Object.entries(index)) {
|
||||
normalized[threadId as ThreadId] =
|
||||
typeof value === "string" ? createThreadIndexEntry(value as CasRef) : value;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function saveThreadsIndex(
|
||||
storageRoot: string,
|
||||
index: ThreadsIndexInput,
|
||||
): Promise<void> {
|
||||
const path = getThreadsPath(storageRoot);
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
const text = stringify(index, { indent: 2 });
|
||||
const text = stringify(serializeThreadsIndex(normalizeThreadsIndexInput(index)), { indent: 2 });
|
||||
await writeFile(path, text, "utf8");
|
||||
}
|
||||
|
||||
@@ -228,7 +307,15 @@ export async function loadThreadHistory(storageRoot: string): Promise<ThreadHist
|
||||
typeof head === "string" &&
|
||||
typeof completedAt === "number"
|
||||
) {
|
||||
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
|
||||
const reason = rec.reason;
|
||||
const parsedReason = reason === "completed" || reason === "cancelled" ? reason : null;
|
||||
lines.push({
|
||||
thread: thread as ThreadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt,
|
||||
reason: parsedReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
type SchemaObj = Record<string, unknown>;
|
||||
|
||||
const RESERVED_NAMES = new Set(["$START", "$END", "$SUSPEND"]);
|
||||
const PSEUDO_TARGETS = new Set(["$END", "$SUSPEND"]);
|
||||
|
||||
/** Extract mustache variable names from a prompt string. */
|
||||
function extractMustacheVars(prompt: string): string[] {
|
||||
const vars: string[] = [];
|
||||
const re = /\{\{\{?([^}]+)\}\}\}?/g;
|
||||
let m: RegExpExecArray | null = re.exec(prompt);
|
||||
while (m !== null) {
|
||||
vars.push(m[1]);
|
||||
m = re.exec(prompt);
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
/** Check if a frontmatter schema is a oneOf (multi-exit) type. */
|
||||
function isOneOfSchema(fm: unknown): fm is SchemaObj & { oneOf: SchemaObj[] } {
|
||||
if (typeof fm !== "object" || fm === null) return false;
|
||||
const obj = fm as SchemaObj;
|
||||
return Array.isArray(obj.oneOf);
|
||||
}
|
||||
|
||||
/** Check if a frontmatter schema uses enum-based multi-exit ($status with multiple enum values). */
|
||||
function isEnumMultiExit(fm: unknown): boolean {
|
||||
if (typeof fm !== "object" || fm === null) return false;
|
||||
const obj = fm as SchemaObj;
|
||||
const props = obj.properties as Record<string, SchemaObj> | undefined;
|
||||
if (!props?.$status) return false;
|
||||
const statusDef = props.$status;
|
||||
if (!Array.isArray(statusDef.enum)) return false;
|
||||
// Filter out "_" (wildcard) — if remaining values > 1, it's multi-exit
|
||||
const statuses = (statusDef.enum as string[]).filter((s) => s !== "_");
|
||||
return statuses.length > 1;
|
||||
}
|
||||
|
||||
/** Extract status values from an enum-based $status field. */
|
||||
function getEnumStatuses(fm: SchemaObj): string[] {
|
||||
const props = fm.properties as Record<string, SchemaObj> | undefined;
|
||||
if (!props?.$status) return [];
|
||||
const statusDef = props.$status;
|
||||
if (!Array.isArray(statusDef.enum)) return [];
|
||||
return (statusDef.enum as string[]).filter((s) => s !== "_");
|
||||
}
|
||||
|
||||
/** Get property names from a schema object. */
|
||||
function getPropertyNames(schema: SchemaObj): Set<string> {
|
||||
const props = schema.properties;
|
||||
if (typeof props !== "object" || props === null) return new Set();
|
||||
return new Set(Object.keys(props as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** Extract $status const values from oneOf variants. */
|
||||
function getOneOfStatuses(variants: SchemaObj[]): string[] {
|
||||
const statuses: string[] = [];
|
||||
for (const variant of variants) {
|
||||
const props = variant.properties as Record<string, SchemaObj> | undefined;
|
||||
if (props?.$status) {
|
||||
const statusDef = props.$status;
|
||||
if (typeof statusDef.const === "string") {
|
||||
statuses.push(statusDef.const);
|
||||
}
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/** Check reserved names and role/graph reference integrity. */
|
||||
function checkRoleReferences(payload: WorkflowPayload, errors: string[]): void {
|
||||
const roleNames = new Set(Object.keys(payload.roles));
|
||||
const graphNodes = new Set(Object.keys(payload.graph));
|
||||
|
||||
for (const name of roleNames) {
|
||||
if (RESERVED_NAMES.has(name)) {
|
||||
errors.push(`reserved name "${name}" must not appear in roles`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of graphNodes) {
|
||||
if (!RESERVED_NAMES.has(node) && !roleNames.has(node)) {
|
||||
errors.push(`graph references unknown role "${node}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of roleNames) {
|
||||
if (RESERVED_NAMES.has(name)) continue;
|
||||
if (!graphNodes.has(name)) {
|
||||
errors.push(`role "${name}" is defined but not referenced in graph`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check $START/$END constraints, edge targets, and reachability. */
|
||||
function checkGraphStructure(payload: WorkflowPayload, errors: string[]): void {
|
||||
const roleNames = new Set(Object.keys(payload.roles));
|
||||
const graphNodes = new Set(Object.keys(payload.graph));
|
||||
|
||||
if (!graphNodes.has("$START")) {
|
||||
errors.push("$START must be defined in graph");
|
||||
} else {
|
||||
const startKeys = Object.keys(payload.graph.$START);
|
||||
if (startKeys.length !== 1 || startKeys[0] !== "_") {
|
||||
errors.push('$START must have exactly one edge with status "_"');
|
||||
}
|
||||
}
|
||||
|
||||
if (graphNodes.has("$END")) {
|
||||
errors.push("$END must not have outgoing edges");
|
||||
}
|
||||
|
||||
if (graphNodes.has("$SUSPEND")) {
|
||||
errors.push("$SUSPEND must not have outgoing edges");
|
||||
}
|
||||
|
||||
for (const [node, statusMap] of Object.entries(payload.graph)) {
|
||||
for (const [status, target] of Object.entries(statusMap)) {
|
||||
if (!PSEUDO_TARGETS.has(target.role) && !roleNames.has(target.role)) {
|
||||
errors.push(`edge ${node}→${status}: unknown target role "${target.role}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkReachability(roleNames, collectReachableRoles(payload.graph), errors);
|
||||
}
|
||||
|
||||
/** BFS to collect all roles reachable from $START. */
|
||||
function collectReachableRoles(graph: WorkflowPayload["graph"]): Set<string> {
|
||||
const reachable = new Set<string>();
|
||||
const startEdges = graph.$START;
|
||||
if (!startEdges) return reachable;
|
||||
|
||||
const queue: string[] = [];
|
||||
for (const target of Object.values(startEdges)) {
|
||||
if (!PSEUDO_TARGETS.has(target.role) && !reachable.has(target.role)) {
|
||||
reachable.add(target.role);
|
||||
queue.push(target.role);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as string;
|
||||
const edges = graph[current];
|
||||
if (!edges) continue;
|
||||
for (const target of Object.values(edges)) {
|
||||
if (!PSEUDO_TARGETS.has(target.role) && !reachable.has(target.role)) {
|
||||
reachable.add(target.role);
|
||||
queue.push(target.role);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reachable;
|
||||
}
|
||||
|
||||
/** Check that all defined roles are reachable from $START. */
|
||||
function checkReachability(roleNames: Set<string>, reachable: Set<string>, errors: string[]): void {
|
||||
for (const name of roleNames) {
|
||||
if (RESERVED_NAMES.has(name)) continue;
|
||||
if (!reachable.has(name)) {
|
||||
errors.push(`role "${name}" is not reachable from $START`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check oneOf discriminant validity for a role. */
|
||||
function checkOneOfDiscriminant(
|
||||
roleName: string,
|
||||
variants: SchemaObj[],
|
||||
statuses: string[],
|
||||
errors: string[],
|
||||
): void {
|
||||
if (statuses.length === variants.length) return;
|
||||
|
||||
let foundMissing = false;
|
||||
for (const variant of variants) {
|
||||
const props = variant.properties as Record<string, SchemaObj> | undefined;
|
||||
if (!props?.$status) {
|
||||
errors.push(`role "${roleName}": oneOf variants must have "$status" as const discriminant`);
|
||||
foundMissing = true;
|
||||
break;
|
||||
}
|
||||
if (typeof props.$status.const !== "string") {
|
||||
errors.push(`role "${roleName}": oneOf variant $status must be a const value`);
|
||||
foundMissing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundMissing) {
|
||||
errors.push(`role "${roleName}": oneOf variant $status must be a const value`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check status-edge consistency for a multi-exit role. */
|
||||
function checkMultiExitEdges(
|
||||
roleName: string,
|
||||
graphKeys: Set<string>,
|
||||
statusSet: Set<string>,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (graphKeys.has("_")) {
|
||||
errors.push(`role "${roleName}" is multi-exit but graph uses "_"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const extraKeys = [...graphKeys].filter((k) => !statusSet.has(k));
|
||||
const missingKeys = [...statusSet].filter((k) => !graphKeys.has(k));
|
||||
if (extraKeys.length > 0) {
|
||||
errors.push(`role "${roleName}" graph has extra status keys: ${extraKeys.join(", ")}`);
|
||||
}
|
||||
if (missingKeys.length > 0) {
|
||||
errors.push(`role "${roleName}" graph is missing status keys: ${missingKeys.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check mustache variables for multi-exit role. */
|
||||
function checkMultiExitMustache(
|
||||
roleName: string,
|
||||
graphEntry: Record<string, { role: string; prompt: string }>,
|
||||
variants: SchemaObj[],
|
||||
errors: string[],
|
||||
): void {
|
||||
for (const [status, target] of Object.entries(graphEntry)) {
|
||||
const vars = extractMustacheVars(target.prompt);
|
||||
const variant = variants.find((v) => {
|
||||
const props = v.properties as Record<string, SchemaObj> | undefined;
|
||||
return props?.$status?.const === status;
|
||||
});
|
||||
if (!variant) continue;
|
||||
const propNames = getPropertyNames(variant);
|
||||
for (const v of vars) {
|
||||
if (v === "$status") continue;
|
||||
if (!propNames.has(v)) {
|
||||
errors.push(`prompt variable "${v}" not found in role "${roleName}" variant "${status}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check status-edge consistency and mustache for each role. */
|
||||
function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void {
|
||||
for (const [roleName, role] of Object.entries(payload.roles)) {
|
||||
if (RESERVED_NAMES.has(roleName)) continue;
|
||||
const graphEntry = payload.graph[roleName];
|
||||
if (!graphEntry) continue;
|
||||
|
||||
const fm = role.frontmatter as unknown;
|
||||
const graphKeys = new Set(Object.keys(graphEntry));
|
||||
|
||||
if (isOneOfSchema(fm)) {
|
||||
const variants = fm.oneOf as SchemaObj[];
|
||||
const statuses = getOneOfStatuses(variants);
|
||||
|
||||
checkOneOfDiscriminant(roleName, variants, statuses, errors);
|
||||
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors);
|
||||
checkMultiExitMustache(roleName, graphEntry, variants, errors);
|
||||
} else if (isEnumMultiExit(fm)) {
|
||||
const statuses = getEnumStatuses(fm as SchemaObj);
|
||||
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors);
|
||||
// For enum-based schemas, mustache vars come from the flat properties
|
||||
checkSingleExitMustache(roleName, graphEntry, fm as SchemaObj, errors);
|
||||
} else {
|
||||
checkSingleExitRole(roleName, graphKeys, graphEntry, fm as SchemaObj | null, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check single-exit role status and mustache. */
|
||||
function checkSingleExitRole(
|
||||
roleName: string,
|
||||
graphKeys: Set<string>,
|
||||
graphEntry: Record<string, { role: string; prompt: string }>,
|
||||
fm: SchemaObj | null,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (graphKeys.size > 1 || (graphKeys.size === 1 && !graphKeys.has("_"))) {
|
||||
if (!graphKeys.has("_")) {
|
||||
errors.push(`role "${roleName}" is single-exit but graph has no "_" key`);
|
||||
} else {
|
||||
errors.push(`role "${roleName}" is single-exit but has status keys other than "_"`);
|
||||
}
|
||||
}
|
||||
|
||||
const singleTarget = graphEntry._;
|
||||
if (!singleTarget) return;
|
||||
|
||||
const vars = extractMustacheVars(singleTarget.prompt);
|
||||
const propNames = fm ? getPropertyNames(fm) : new Set<string>();
|
||||
for (const v of vars) {
|
||||
if (v === "$status") continue;
|
||||
if (!propNames.has(v)) {
|
||||
errors.push(`prompt variable "${v}" not found in role "${roleName}" frontmatter`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check mustache vars in all edge prompts against flat schema properties. */
|
||||
function checkSingleExitMustache(
|
||||
roleName: string,
|
||||
graphEntry: Record<string, { role: string; prompt: string }>,
|
||||
fm: SchemaObj,
|
||||
errors: string[],
|
||||
): void {
|
||||
const propNames = getPropertyNames(fm);
|
||||
for (const [status, target] of Object.entries(graphEntry)) {
|
||||
const vars = extractMustacheVars(target.prompt);
|
||||
for (const v of vars) {
|
||||
if (v === "$status") continue;
|
||||
if (!propNames.has(v)) {
|
||||
errors.push(
|
||||
`prompt variable "${v}" in graph[${roleName}][${status}] not found in role "${roleName}" frontmatter`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a parsed WorkflowPayload for semantic correctness.
|
||||
* Returns an array of error messages. Empty array = valid.
|
||||
*/
|
||||
export function validateWorkflow(payload: WorkflowPayload): string[] {
|
||||
const errors: string[] = [];
|
||||
checkRoleReferences(payload, errors);
|
||||
checkGraphStructure(payload, errors);
|
||||
checkRoleConsistency(payload, errors);
|
||||
return errors;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { basename } from "node:path";
|
||||
import { basename, dirname } from "node:path";
|
||||
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
@@ -16,7 +16,9 @@ function isRoleDefinition(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
const frontmatter = value.frontmatter;
|
||||
const frontmatterOk = isRecord(frontmatter) && typeof frontmatter.type === "string";
|
||||
const frontmatterOk =
|
||||
isRecord(frontmatter) &&
|
||||
(typeof frontmatter.type === "string" || Array.isArray(frontmatter.oneOf));
|
||||
const capabilities = value.capabilities;
|
||||
const capabilitiesOk =
|
||||
Array.isArray(capabilities) && capabilities.every((c) => typeof c === "string");
|
||||
@@ -34,8 +36,13 @@ function isTarget(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const hasValidLocation =
|
||||
value.location === undefined || value.location === null || typeof value.location === "string";
|
||||
return (
|
||||
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
|
||||
typeof value.role === "string" &&
|
||||
typeof value.prompt === "string" &&
|
||||
value.prompt.trim() !== "" &&
|
||||
hasValidLocation
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,9 +68,15 @@ function isGraph(value: unknown): boolean {
|
||||
*/
|
||||
export function workflowNameFromPath(filePath: string): string {
|
||||
const base = basename(filePath);
|
||||
if (base.endsWith(".yaml")) return base.slice(0, -5);
|
||||
if (base.endsWith(".yml")) return base.slice(0, -4);
|
||||
return base;
|
||||
const stem = base.endsWith(".yaml")
|
||||
? base.slice(0, -5)
|
||||
: base.endsWith(".yml")
|
||||
? base.slice(0, -4)
|
||||
: base;
|
||||
if (stem === "index") {
|
||||
return basename(dirname(filePath));
|
||||
}
|
||||
return stem;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,5 +106,22 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
|
||||
return null;
|
||||
}
|
||||
return raw as WorkflowPayload;
|
||||
|
||||
// Normalize location field: undefined → null
|
||||
const normalized = { ...raw } as WorkflowPayload;
|
||||
for (const roleName of Object.keys(normalized.graph)) {
|
||||
const statusMap = normalized.graph[roleName];
|
||||
if (statusMap !== undefined) {
|
||||
for (const status of Object.keys(statusMap)) {
|
||||
const target = statusMap[status];
|
||||
if (target !== undefined) {
|
||||
if (target.location === undefined) {
|
||||
target.location = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,5 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-moderator" },
|
||||
{ "path": "../workflow-agent-kit" }
|
||||
]
|
||||
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,7 @@ Layer 3 agent implementation. Runs an OpenAI-compatible chat completion loop wit
|
||||
|
||||
Useful when you want a self-contained agent without an external CLI like Hermes or Claude Code.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-util`
|
||||
**Dependencies:** `@ocas/core`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-util`
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { AgentContext } from "@uncaged/workflow-agent-kit";
|
||||
import type { AgentContext } from "@uncaged/workflow-util-agent";
|
||||
|
||||
import { buildBuiltinMessages } from "../src/prompt.js";
|
||||
|
||||
|
||||
@@ -18,11 +18,13 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "bun test __tests__/",
|
||||
"test:ci": "bun test __tests__/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"@uncaged/workflow-agent-kit": "workspace:^",
|
||||
"@ocas/core": "^0.1.1",
|
||||
"@uncaged/workflow-util-agent": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -30,5 +32,15 @@
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/workflow-agent-builtin"
|
||||
},
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import type { Store } from "@ocas/core";
|
||||
import { createLogger, generateUlid } from "@uncaged/workflow-util";
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentRunResult,
|
||||
@@ -6,8 +7,7 @@ import {
|
||||
loadWorkflowConfig,
|
||||
resolveModel,
|
||||
resolveStorageRoot,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
import { createLogger, generateUlid } from "@uncaged/workflow-util";
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
|
||||
import { storeBuiltinDetail } from "./detail.js";
|
||||
import type { ChatMessage } from "./llm/index.js";
|
||||
@@ -82,7 +82,7 @@ async function runBuiltinWithMessages(
|
||||
|
||||
if (loopResult.turnCount === 0) {
|
||||
log("5RWTK9NB", "no turns produced, returning empty output");
|
||||
return { output: "", detailHash: "", sessionId: session.sessionId };
|
||||
return { output: "", detailHash: "", sessionId: session.sessionId, assembledPrompt: "" };
|
||||
}
|
||||
|
||||
// Read jsonl → persist turns to CAS → store detail
|
||||
@@ -94,7 +94,12 @@ async function runBuiltinWithMessages(
|
||||
session.startedAtMs,
|
||||
);
|
||||
|
||||
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
|
||||
return {
|
||||
output: stripPreamble(loopResult.finalText),
|
||||
detailHash,
|
||||
sessionId: session.sessionId,
|
||||
assembledPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user