Compare commits

...

8 Commits

Author SHA1 Message Date
xiaoju 45f479e60f feat(protocol): add step-level timing (startedAtMs / completedAtMs) (#489)
BREAKING CHANGE: StepRecord now requires startedAtMs and completedAtMs fields.
StepEntry now requires durationMs field. Old CAS data without these fields is invalid.

- Add startedAtMs/completedAtMs to StepRecord and StepNodePayload
- Add durationMs to StepEntry (computed: completedAtMs - startedAtMs)
- Update STEP_NODE_SCHEMA to require timing fields as integers
- Record Date.now() before/after agent execution in createAgent
- Show duration in thread read headers (formatStepHeader)
- Update existing test fixtures with timing fields
2026-05-25 08:01:50 +00:00
xiaoju 3fca67e443 fix: isRoleDefinition accepts oneOf frontmatter
Without this, parseWorkflowPayload rejects workflows with oneOf
frontmatter before semantic validation even runs.
2026-05-25 07:46:12 +00:00
xiaoju 9b2460633c feat(cli): add workflow semantic validation before execution
Implements validateWorkflow() that performs deep semantic checks on
parsed WorkflowPayload before registration or execution:

- Role reference integrity (unknown roles, orphans, reserved names)
- Graph structure (/ constraints, reachability, edge targets)
- Status-edge consistency (single/multi-exit matching)
- Mustache template variable existence
- oneOf discriminant validity ( const check)

All errors collected (not fail-fast). Integrated into:
- uwf workflow add (before CAS registration)
- uwf thread start (local workflow materialization)

Closes #506
2026-05-25 07:25:10 +00:00
xiaoju dfb6fda06d feat(agent-kit): render per-variant output instructions for discriminated oneOf
buildOutputFormatInstruction now detects discriminated union schemas
(oneOf with shared const/ property) and renders separate YAML
example blocks per variant, so agents see exactly which fields belong
to which outcome instead of a flat merge.

Non-discriminated oneOf/anyOf schemas fall back to the existing flat
merge behavior.

Refs #502
2026-05-25 06:54:38 +00:00
xiaoju 827ff13c4a refactor: discriminated union frontmatter for solve-issue workflow
- planner: oneOf ready (plan, repoPath) | insufficient_info
- developer: single exit, plain object (branch, worktree), no $status
- reviewer: oneOf approved (branch, worktree) | rejected (comments)
- tester: oneOf passed (branch, worktree) | fix_code (report) | fix_spec (report)
- committer: oneOf committed (prUrl) | hook_failed (error)
- Edge prompts now use mustache templates with variant-specific fields
- Developer simplified from 2 exits to single exit (unit routing)

Phase 2 of #499 (closes #501)
2026-05-25 06:34:56 +00:00
xiaoju 7a19ceca89 refactor: rename status to $status, default to _ when absent
- evaluate() reads $status instead of status, defaults to _ when missing
- Update all YAML examples and .workflows to use $status
- Update cli-workflow resolveEvaluateArgs to use $status
- 10 moderator tests pass including new default _ test
- Single-exit roles no longer need to declare status field

Phase 1 of #499 (closes #500)
2026-05-25 06:22:53 +00:00
xiaoju 298b944169 docs: update all documentation for status-based routing (#497)
Replace all JSONata/ConditionDefinition/ConditionalEdge references with
status-based routing terminology across 8 files:

- README.md, CLAUDE.md: moderator description, key terms
- docs/architecture.md: dependency jsonata→mustache, evaluate signature
- docs/wf-stateless-design.md: type definitions, routing context
- packages/workflow-moderator/README.md: full rewrite for new API
- packages/workflow-protocol/README.md: Target type, remove Transition
- packages/workflow-dashboard/context.md: StatusEdge, graph type
- docs/builtin-agent-research.md: stale JSONata references
2026-05-25 05:52:27 +00:00
xiaoju e40e41555b refactor: dashboard status-based edge routing
- Rename ConditionalEdge → StatusEdge, condition → status throughout
- Rename conditional.tsx → status.tsx, edge label shows status value
- Update trans-in/trans-out to use status field instead of condition
- Update validate to check status edges
- Align server/workflow.ts with new WorkflowPayload.graph format
- 20 dashboard tests pass

Phase 3 of #490 (closes #493)
2026-05-25 05:05:57 +00:00
46 changed files with 1624 additions and 429 deletions
+61 -88
View File
@@ -22,16 +22,17 @@ 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)."
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 }
required: [$status, plan, repoPath]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
@@ -58,14 +59,13 @@ roles:
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)."
output: "List all files changed and provide a summary. Include branch name and worktree path in frontmatter."
frontmatter:
type: object
properties:
status:
type: string
enum: [done, failed]
required: [status]
branch: { type: string }
worktree: { type: string }
required: [branch, worktree]
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)."
@@ -95,13 +95,18 @@ roles:
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
type: object
properties:
approved:
type: boolean
required: [approved]
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
required: [$status, comments]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
@@ -117,14 +122,22 @@ roles:
- 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 }
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."
@@ -145,72 +158,32 @@ roles:
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: success (true or false)."
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:
success:
type: boolean
required: [success]
conditions:
insufficientInfo:
description: "Planner determined there's not enough info to proceed"
expression: "$last('planner').status = 'insufficient_info'"
devFailed:
description: "Developer failed to implement"
expression: "$last('developer').status = 'failed'"
rejected:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
fixCode:
description: "Tester found code issues"
expression: "$last('tester').status = 'fix_code'"
fixSpec:
description: "Tester found spec issues"
expression: "$last('tester').status = 'fix_spec'"
hookFailed:
description: "Push hook failed"
expression: "$last('committer').success = false"
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"
condition: null
prompt: "Analyze the issue and produce an implementation plan."
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
- role: "$END"
condition: "insufficientInfo"
prompt: "Insufficient information to proceed; end the workflow."
- role: "developer"
condition: null
prompt: "Implement the plan from the planner."
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
developer:
- role: "$END"
condition: "devFailed"
prompt: "Development failed; end the workflow."
- role: "reviewer"
condition: null
prompt: "Send the implementation to the reviewer."
_: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
reviewer:
- role: "developer"
condition: "rejected"
prompt: "Reviewer rejected the implementation; fix the issues."
- role: "tester"
condition: null
prompt: "Review passed; run tests on the implementation."
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
tester:
- role: "developer"
condition: "fixCode"
prompt: "Tests found code issues; return to developer."
- role: "planner"
condition: "fixSpec"
prompt: "Tests found spec issues; return to planner."
- role: "committer"
condition: null
prompt: "Tests passed; commit and push the changes."
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:
- role: "developer"
condition: "hookFailed"
prompt: "Push hook failed; return to developer to fix."
- role: "$END"
condition: null
prompt: "Commit succeeded; complete the workflow."
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
+3 -3
View File
@@ -8,10 +8,10 @@ This monorepo implements a stateless workflow engine driven by a single-step CLI
| Concept | What it is |
|---------|-----------|
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, conditions, and a routing graph. Stored as a CAS node, identified by its XXH64 hash. |
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, status-based routing, and a directed graph. Stored as a CAS node, identified by its XXH64 hash. |
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
| **Moderator** | JSONata-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
| **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. |
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
@@ -23,7 +23,7 @@ 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 — JSONata graph evaluator
workflow-moderator/ # @uncaged/workflow-moderator — Status-based graph evaluator
workflow-agent-kit/ # @uncaged/workflow-agent-kit — createAgent factory, context builder, extract pipeline
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
+4 -4
View File
@@ -1,10 +1,10 @@
# @uncaged/workflow
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, JSONata routing conditions, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
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
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates JSONata conditions to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates status-based routing to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
@@ -20,7 +20,7 @@ Layer 0 — Contract
Layer 1 — Shared infra
workflow-util Encoding, IDs, logging, frontmatter, paths
workflow-moderator JSONata graph evaluator
workflow-moderator Status-based graph evaluator
Layer 2 — Agent framework
workflow-agent-kit createAgent factory, context builder, extract pipeline
@@ -47,7 +47,7 @@ See [docs/architecture.md](docs/architecture.md) for the full design — three-p
|---------|-----|-------------|------|--------|
| `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` | JSONata graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/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) |
+7 -8
View File
@@ -16,7 +16,7 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|-------|---------|---------------|
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
| Shared infra | `@uncaged/workflow-util``workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
| Moderator | `@uncaged/workflow-moderator``workflow-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
| 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: 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. |
@@ -27,7 +27,7 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|---------|------|
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
| `jsonata` | JSONata expression evaluator (used by `workflow-moderator`). |
| `mustache` | Template renderer for edge prompts (used by `workflow-moderator`). |
| `commander` | CLI argument parsing (used by `cli-workflow`). |
| `dotenv` | Loads `.env` files for API keys. |
| `yaml` | YAML parse/stringify. |
@@ -148,8 +148,7 @@ graph:
Key properties:
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
- **`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`
@@ -159,8 +158,8 @@ Each `uwf thread step` runs exactly one cycle: moderator → agent → extract.
```
┌─→ Phase 1: MODERATOR
│ Input: WorkflowPayload + ModeratorContext { start, steps[] }
│ Engine: JSONata conditions evaluated against the graph
│ Input: graph + lastRole + lastOutput
│ Engine: Status-based map lookup against lastOutput.status
│ Output: next role name | $END
│ Phase 2: AGENT
@@ -207,7 +206,7 @@ type AgentContext = ModeratorContext & {
### Key properties
- **Moderator** — pure JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates `workflow.graph[currentRole]` transitions in order, returns first match.
- **Moderator** — pure status-based map lookup; no LLM call, no I/O beyond CAS reads. Looks up `graph[lastRole][lastOutput.status]` to get the next target.
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
@@ -485,7 +484,7 @@ Binary: `uwf`
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
| **Stateless single-step CLI** | Each `uwf thread step` is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
| **JSONata moderator** | Declarative condition expressions evaluated against thread history. No LLM cost for routing decisions. |
| **Status-based moderator** | Status-based map routing — `graph[role][status]` lookup against last output. No LLM cost for routing decisions. |
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
+2 -2
View File
@@ -288,7 +288,7 @@ export type BuildContextMeta = {
1. 从 `threads.yaml[threadId]` 取 `headHash`
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / JSONata 使用)
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
#### Role definition 来源
@@ -572,7 +572,7 @@ Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes
| P1 | `grep` | 搜索符号/引用 |
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + JSONata 负责。
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
#### Agent loop 必须能力
+23 -44
View File
@@ -75,7 +75,7 @@ uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
**做的事:**
1. 读链头 → 当前 StepNode(或 StartNode)
2. 收集 thread 历史(遍历链)
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
4. 若 END → 归档 thread,输出最后链头,退出
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
@@ -199,29 +199,21 @@ payload:
```
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `conditions``Record<Name, JSONata>`,命名条件,方便画图描述
- `graph``Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
- `condition` 引用 conditions 中的 key,`null` = fallback
- 按数组顺序求值,第一个匹配的 transition 胜出
- `graph``Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
- Status 来自上一个 role 输出的 `status` 字段,`$START``_` 作为初始 status
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
JSONata 表达式的求值上下文
Moderator 的求值逻辑
```jsonc
{
"start": { // StartNode 信息
"workflow": "4KNM2PXR3B1QW",
"prompt": "Fix the login bug..."
},
"steps": [ // 所有已完成 steps,从旧到新
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
]
}
```typescript
evaluate(graph, lastRole, lastOutput) { role, prompt }
// 1. status = lastRole === "$START" ? "_" : lastOutput.status
// 2. target = graph[lastRole][status]
// 3. prompt = mustache.render(target.prompt, lastOutput)
```
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target
#### `StartNode`(Thread 起点)
@@ -350,7 +342,7 @@ OPENROUTER_API_KEY=sk-or-...
```
packages/
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
├── workflow-moderator/ # @uncaged/workflow-moderator — JSONata moderator 引擎
├── workflow-moderator/ # @uncaged/workflow-moderator — Status-based moderator 引擎
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
@@ -367,7 +359,7 @@ packages/
## 4. 关键数据类型
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
### 4.1 公共类型
@@ -378,7 +370,7 @@ type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
@@ -399,22 +391,16 @@ type RoleDefinition = {
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
};
type Transition = {
type Target = {
role: string; // 目标 role 名 或 "$END"
condition: string | null; // 引用 conditions 中的 key,null = fallback
};
type ConditionDefinition = {
description: string;
expression: string; // JSONata expression
prompt: string; // Mustache 模板,渲染时注入 lastOutput
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
};
```
@@ -432,20 +418,14 @@ type StepNodePayload = StepRecord & {
};
```
### 4.4 JSONata 求值上下文
### 4.4 Moderator 求值
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
```typescript
/** JSONata 上下文中的 step — output 被展开 */
type StepContext = Omit<StepRecord, "output"> & {
output: unknown; // 展开后的 CAS 节点内容,非 hash
};
type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[]; // 从旧到新
};
// graph[lastRole][lastOutput.status] → Target { role, prompt }
// $START 角色使用 "_" 作为初始 status
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
```
### 4.5 CLI 输出
@@ -534,6 +514,5 @@ StepNodePayload ──extends──→ StepRecord ←──maps to──→ Step
└── start.workflow → WorkflowPayload
├── roles: Record<name, RoleDefinition>
── conditions: Record<name, JSONata>
└── graph: Record<role, Transition[]>
── graph: Record<role, Record<status, Target>>
```
+2 -2
View File
@@ -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." }
+4 -4
View File
@@ -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." }
+6 -6
View File
@@ -27,13 +27,13 @@ roles:
frontmatter:
type: object
properties:
status:
$status:
enum: ["_"]
repoPath:
type: string
plan:
type: string
required: [status, repoPath, plan]
required: [$status, repoPath, plan]
developer:
description: "Implements code changes"
goal: "You are a developer agent. You implement code changes according to plans."
@@ -52,7 +52,7 @@ roles:
frontmatter:
type: object
properties:
status:
$status:
enum: ["_"]
filesChanged:
type: array
@@ -60,7 +60,7 @@ roles:
type: string
summary:
type: string
required: [status, filesChanged, summary]
required: [$status, filesChanged, summary]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. You review implementations for correctness and quality."
@@ -75,11 +75,11 @@ roles:
frontmatter:
type: object
properties:
status:
$status:
enum: ["approved", "rejected"]
comments:
type: string
required: [status, comments]
required: [$status, comments]
graph:
$START:
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
@@ -81,17 +81,18 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
expect(workflow.roles.committer?.frontmatter).toBeDefined();
});
test("committer frontmatter schema should require success 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?.success).toBeDefined();
expect(frontmatter?.properties?.success?.type).toBe("boolean");
expect(frontmatter?.required).toContain("success");
expect(frontmatter?.oneOf).toBeDefined();
const committedVariant = frontmatter.oneOf.find(
(v: any) => v.properties?.["$status"]?.const === "committed",
);
expect(committedVariant).toBeDefined();
expect(committedVariant.required).toContain("$status");
});
});
@@ -144,6 +144,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step with large quota
@@ -227,6 +229,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step with limited quota (700 chars)
@@ -304,6 +308,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step with minimal quota (1 char)
@@ -357,6 +363,8 @@ describe("step read", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step - should return metadata only (no error)
@@ -431,6 +439,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step - should return metadata only (no error)
@@ -505,6 +515,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step
@@ -0,0 +1,378 @@
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 type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
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: "cas_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;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── 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,
};
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,
});
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" } },
worker: { _: { role: "$END", prompt: "" } },
},
});
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" } },
worker: { _: { role: "$END", prompt: "" } },
},
});
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");
});
});
@@ -141,6 +141,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
@@ -221,6 +223,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: step1DetailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step2Content = generateContent(600, "Second");
@@ -245,6 +249,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: step2DetailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
@@ -328,6 +334,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
@@ -338,8 +346,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 +413,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
@@ -480,6 +490,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
@@ -559,6 +571,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
@@ -139,6 +139,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000001" as ThreadId;
@@ -214,6 +216,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000002" as ThreadId;
@@ -274,6 +278,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -283,6 +289,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000003" as ThreadId;
@@ -335,6 +343,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000004" as ThreadId;
@@ -387,6 +397,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: missingDetailRef,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000005" as ThreadId;
@@ -439,6 +451,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000006" as ThreadId;
@@ -511,6 +525,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -520,6 +536,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -529,6 +547,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000007" as ThreadId;
@@ -607,6 +627,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000008" as ThreadId;
@@ -661,6 +683,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
})) as CasRef;
steps.push(step);
prev = step;
@@ -0,0 +1,366 @@
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
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" } },
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}" } },
reviewer: {
approved: { role: "$END", prompt: "Done: {{{summary}}}" },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
},
},
};
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" } };
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" },
other: { role: "reviewer", prompt: "Also" },
};
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" } };
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" } };
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" } };
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" } };
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" },
extra: { role: "$END", prompt: "Done" },
};
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" } };
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" },
rejected: { role: "writer", prompt: "Fix" },
timeout: { role: "$END", prompt: "Timed out" },
};
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" },
};
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" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).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}}}" } };
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}}}" },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
};
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}}" } };
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" } };
// bad mustache var
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}" } };
const errors = validateWorkflow(wf);
expect(errors.length).toBeGreaterThanOrEqual(3);
});
});
@@ -20,23 +20,37 @@ async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
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: "0000000000000" } as unknown as CasRef,
},
},
graph: {
$START: { _: { role: "worker", prompt: "start working" } },
worker: { _: { role: "$END", prompt: "done" } },
},
};
}
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;
}
@@ -58,6 +58,7 @@ export async function cmdStepList(
detail: item.payload.detail ?? null,
agent: item.payload.agent,
timestamp: item.timestamp,
durationMs: item.payload.completedAtMs - item.payload.startedAtMs,
});
}
+23 -5
View File
@@ -40,6 +40,7 @@ import {
type UwfStore,
} from "../store.js";
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
import { validateWorkflow } from "../validate-semantic.js";
import {
type ChainState,
collectOrderedSteps,
@@ -169,6 +170,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);
@@ -560,14 +566,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 +686,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 +708,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,
};
}
@@ -15,6 +15,7 @@ import {
type UwfStore,
} from "../store.js";
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
import { validateWorkflow } from "../validate-semantic.js";
export type WorkflowOrigin = "local" | "global";
@@ -136,6 +137,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,278 @@
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
type SchemaObj = Record<string, unknown>;
const RESERVED_NAMES = new Set(["$START", "$END"]);
/** 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);
}
/** 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");
}
for (const [node, statusMap] of Object.entries(payload.graph)) {
for (const [status, target] of Object.entries(statusMap)) {
if (target.role !== "$END" && !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 (target.role !== "$END" && !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 (target.role !== "$END" && !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 {
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`);
}
}
}
/**
* 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;
}
+3 -1
View File
@@ -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");
@@ -87,7 +87,7 @@ describe("buildOutputFormatInstruction", () => {
expect(result).toContain("beta: <number>");
});
test("lists union of fields from a oneOf schema", () => {
test("lists union of fields from a oneOf schema (no discriminant — flat merge)", () => {
const schema = {
oneOf: [
{
@@ -101,12 +101,71 @@ describe("buildOutputFormatInstruction", () => {
],
};
const result = buildOutputFormatInstruction(schema);
// No discriminant detected → falls back to flat merge
expect(result).toContain("`foo`");
expect(result).toContain("`bar`");
expect(result).toContain("foo: <string>");
expect(result).toContain("bar: true # true | false");
});
test("renders per-variant instructions for discriminated oneOf", () => {
const schema = {
oneOf: [
{
type: "object",
properties: {
$status: { const: "ready" },
plan: { type: "string" },
},
required: ["$status", "plan"],
},
{
type: "object",
properties: {
$status: { const: "insufficient_info" },
},
required: ["$status"],
},
],
};
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("Choose ONE of the following variants");
expect(result).toContain("When `$status: ready`");
expect(result).toContain("When `$status: insufficient_info`");
expect(result).toContain("plan: <string>");
// The insufficient_info variant should NOT mention plan
const insufficientBlock = result.split("When `$status: insufficient_info`")[1];
expect(insufficientBlock).not.toContain("plan:");
});
test("renders per-variant for single-enum discriminant", () => {
const schema = {
oneOf: [
{
type: "object",
properties: {
$status: { type: "string", enum: ["approved"] },
branch: { type: "string" },
},
required: ["$status"],
},
{
type: "object",
properties: {
$status: { type: "string", enum: ["rejected"] },
comments: { type: "string" },
},
required: ["$status"],
},
],
};
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("When `$status: approved`");
expect(result).toContain("When `$status: rejected`");
expect(result).toContain("branch: <string>");
expect(result).toContain("comments: <string>");
});
test("falls back gracefully for a non-object schema with no properties", () => {
const result = buildOutputFormatInstruction({ type: "string" });
expect(result).toContain("schema fields will be extracted automatically");
@@ -166,14 +166,109 @@ function buildFieldList(properties: SchemaProperty[]): string {
.join("\n");
}
/**
* Detect the discriminant property name from a oneOf schema.
* Returns the property name if all variants share a const/single-enum string property, else null.
*/
function detectDiscriminant(variants: JSONSchema[]): string | null {
// Find property names that appear in ALL variants with const or single-enum
const candidateNames = new Set<string>();
for (const variant of variants) {
const props = variant.properties as Record<string, JSONSchema> | null | undefined;
if (typeof props !== "object" || props === null) return null;
for (const [name, propSchema] of Object.entries(props)) {
const isConst =
propSchema.const !== undefined ||
(Array.isArray(propSchema.enum) && propSchema.enum.length === 1);
if (isConst) candidateNames.add(name);
}
}
// Check which candidate appears in ALL variants
for (const name of candidateNames) {
const allHaveIt = variants.every((v) => {
const props = v.properties as Record<string, JSONSchema> | null | undefined;
if (typeof props !== "object" || props === null) return false;
const propSchema = props[name];
if (!propSchema) return false;
return (
propSchema.const !== undefined ||
(Array.isArray(propSchema.enum) && propSchema.enum.length === 1)
);
});
if (allHaveIt) return name;
}
return null;
}
function getConstValue(propSchema: JSONSchema): string {
if (propSchema.const !== undefined) return String(propSchema.const);
if (Array.isArray(propSchema.enum) && propSchema.enum.length === 1)
return String(propSchema.enum[0]);
return "<unknown>";
}
function buildVariantBlock(variant: JSONSchema, discriminant: string): string {
const props = extractSchemaProperties(variant);
const value = getConstValue(
((variant.properties as Record<string, JSONSchema>) ?? {})[discriminant] ?? {},
);
const yamlExample = buildYamlExampleBlock(props);
const fieldList = buildFieldList(props);
return `### When \`${discriminant}: ${value}\`
\`\`\`
${yamlExample}
\`\`\`
Fields:
${fieldList}`;
}
/**
* Build a concise output format instruction block for an agent role.
*
* The instruction describes the expected frontmatter markdown format and lists
* the meta fields derived from the JSON Schema. It is prepended to the agent's
* system prompt so the deliverable format is the first thing the agent sees.
* For discriminated union schemas (oneOf with a shared const/$status field),
* renders per-variant instructions so the agent knows exactly which fields
* belong to which outcome.
*
* For flat object schemas, renders a single YAML example block.
*/
export function buildOutputFormatInstruction(schema: JSONSchema): string {
// Check for discriminated union (oneOf with shared discriminant)
const unionKey = Array.isArray(schema.oneOf)
? "oneOf"
: Array.isArray(schema.anyOf)
? "anyOf"
: null;
if (unionKey !== null) {
const variants = schema[unionKey] as JSONSchema[];
const discriminant = detectDiscriminant(variants);
if (discriminant !== null && variants.length > 1) {
const variantBlocks = variants.map((v) => buildVariantBlock(v, discriminant)).join("\n\n");
return `## Deliverable Format
Your response MUST begin with a YAML frontmatter block followed by your markdown work.
Choose ONE of the following variants based on your outcome:
${variantBlocks}
The frontmatter is the **primary deliverable** — the engine reads it directly.
Output ONLY the fields listed for your chosen variant. Do not add extra fields that are not specified in the schema.
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
}
}
// Flat object schema fallback
const properties = extractSchemaProperties(schema);
const yamlExample = buildYamlExampleBlock(properties);
const fieldList = buildFieldList(properties);
@@ -128,6 +128,8 @@ async function buildHistory(
detail: step.detail,
agent: step.agent,
edgePrompt: step.edgePrompt ?? "",
startedAtMs: step.startedAtMs,
completedAtMs: step.completedAtMs,
content,
});
}
+12 -1
View File
@@ -59,6 +59,8 @@ async function writeStepNode(options: {
detailHash: CasRef;
agentName: string;
edgePrompt: string;
startedAtMs: number;
completedAtMs: number;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
@@ -68,6 +70,8 @@ async function writeStepNode(options: {
detail: options.detailHash,
agent: options.agentName,
edgePrompt: options.edgePrompt,
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
};
const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash);
@@ -94,6 +98,8 @@ async function persistStep(options: {
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
startedAtMs: number;
completedAtMs: number;
}): Promise<CasRef> {
const { store, schemas, chain, headHash } = options.ctx.meta;
return writeStepNode({
@@ -106,6 +112,8 @@ async function persistStep(options: {
detailHash: options.detailHash,
agentName: options.agentName,
edgePrompt: options.ctx.edgePrompt,
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
});
}
@@ -127,6 +135,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
}
const startedAtMs = Date.now();
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
// Preserve the primary detail from the first run — it contains the full
@@ -156,12 +165,14 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
`Raw output (first 500 chars): ${agentResult.output.slice(0, 500)}`,
);
}
const completedAtMs = Date.now();
const stepHash = await persistStep({
ctx,
outputHash,
detailHash: primaryDetailHash,
agentName: agentLabel(options.name),
startedAtMs,
completedAtMs,
});
process.stdout.write(`${stepHash}\n`);
+4 -5
View File
@@ -123,7 +123,7 @@ type RoleNodeData = {
**边类型**
- `default`(GradientEdge)→ 渐变色边(绿→蓝),节点仅有一条出边时使用
- `conditional`(ConditionalEdge)→ 带条件标签的渐变色边,节点有多条出边时使用
- `status`(StatusEdge)→ 带 status 标签的渐变色边,节点有多条出边时使用
**边渲染特性**
- 渐变色:SVG linearGradient,从 source 端绿色(#10b981)到 target 端蓝色(#3b82f6
@@ -234,7 +234,7 @@ Model 提供事务机制:
```
ReactFlow
├─ nodeTypes: { start: NodeStart, end: NodeEnd, role: NodeRole }
└─ edgeTypes: { default: GradientEdge, conditional: ConditionalEdge }
└─ edgeTypes: { default: GradientEdge, status: StatusEdge }
```
`NodeRole` 显示角色名(data.name),使用 teal 色系图标和标签。Handle 分蓝色(in)和绿色(out)两种颜色。
@@ -324,12 +324,11 @@ type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>; // 角色定义(4 段式:identity/prepare/execute/report)
conditions: Record<string, ConditionDefinition>; // JSONata 条件表达式
graph: Record<string, Transition[]>; // 角色间的转移图
graph: Record<string, Record<string, Target>>; // status-based 路由图
};
```
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Transition`。外部(CLI/server)负责 `WorkflowPayload``WorkFlowSteps` 的转换。
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Target`。外部(CLI/server)负责 `WorkflowPayload``WorkFlowSteps` 的转换。
## 11. 当前状态与待完善项
+1 -1
View File
@@ -57,7 +57,7 @@ export function createApi() {
transitions: t.Array(
t.Object({
target: t.String(),
condition: t.Union([t.String(), t.Null()]),
status: t.String(),
}),
),
}),
+15 -40
View File
@@ -1,6 +1,6 @@
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
import type { RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import YAML from "yaml";
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
@@ -11,17 +11,12 @@ async function ensureDir() {
}
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
const conditionMap = new Map<string, string>();
for (const [name, def] of Object.entries(payload.conditions)) {
conditionMap.set(name, def.expression);
}
const steps: WorkFlowSteps = [];
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
const graphTransitions = payload.graph[roleName] ?? [];
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
target: t.role === "$END" ? "END" : t.role,
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
const statusMap = payload.graph[roleName] ?? {};
const transitions: WorkFlowTransition[] = Object.entries(statusMap).map(([status, target]) => ({
target: target.role === "$END" ? "END" : target.role,
status,
}));
steps.push({
@@ -42,11 +37,7 @@ function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
const roles: Record<string, RoleDefinition> = {};
const conditions: WorkflowPayload["conditions"] = {};
const graph: Record<string, Transition[]> = {};
const expressionToName = new Map<string, string>();
let condIdx = 0;
const graph: Record<string, Record<string, Target>> = {};
for (const step of steps) {
const r = step.role;
@@ -59,43 +50,28 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
frontmatter: "",
};
const transitions: Transition[] = step.transitions.map((t) => {
let condName: string | null = null;
if (t.condition) {
if (expressionToName.has(t.condition)) {
condName = expressionToName.get(t.condition) ?? null;
} else {
condName = `cond${condIdx++}`;
expressionToName.set(t.condition, condName);
conditions[condName] = {
description: "",
expression: t.condition,
};
}
}
const statusMap: Record<string, Target> = {};
for (const t of step.transitions) {
const targetRole = t.target === "END" ? "$END" : t.target;
return {
statusMap[t.status] = {
role: targetRole,
condition: condName,
prompt: `Transition to ${targetRole}.`,
};
});
graph[r.name] = transitions;
}
graph[r.name] = statusMap;
}
if (steps.length > 0) {
const firstRole = steps[0].role.name;
graph.$START = [
{
graph.$START = {
_: {
role: firstRole,
condition: null,
prompt: `Begin workflow at role ${firstRole}.`,
},
];
};
}
return { name, description, roles, conditions, graph };
return { name, description, roles, graph };
}
export async function listWorkflows(): Promise<WorkflowSummary[]> {
@@ -125,7 +101,6 @@ export async function createWorkflow(name: string, description: string): Promise
name,
description,
roles: {},
conditions: {},
graph: {},
};
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
+1 -1
View File
@@ -9,7 +9,7 @@ export type WorkFlowRole = {
export type WorkFlowTransition = {
target: string;
condition: string | null;
status: string;
};
export type WorkFlowStep = {
@@ -1,6 +1,6 @@
import { ConditionalEdge, GradientEdge } from "./conditional";
import { GradientEdge, StatusEdge } from "./status";
export const edgeTypes = {
conditional: ConditionalEdge,
status: StatusEdge,
default: GradientEdge,
};
@@ -6,10 +6,10 @@ import {
useReactFlow,
} from "@xyflow/react";
import { Check } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { cn } from "../../lib/utils.ts";
import { useModel } from "../context.tsx";
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
import type { StatusEdge as StatusEdgeType } from "../type.ts";
const SOURCE_COLOR = "#10b981";
const TARGET_COLOR = "#3b82f6";
@@ -23,7 +23,7 @@ function GradientPath({
sourceY,
targetX,
targetY,
hasCondition,
hasStatus,
selected,
}: {
id: string;
@@ -32,11 +32,11 @@ function GradientPath({
sourceY: number;
targetX: number;
targetY: number;
hasCondition: boolean | null;
hasStatus: boolean;
selected: boolean;
}) {
const gradientId = `gradient-${id}`;
const showLack = hasCondition === false;
const showLack = !hasStatus;
const strokeStyle = selected
? { stroke: "#f59e0b", strokeWidth: 2 }
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
@@ -68,35 +68,20 @@ function GradientPath({
);
}
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
return (
<div
className="absolute pointer-events-none"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
}}
>
<span className="inline-block px-1 bg-white rounded text-[10px] border border-gray-300 text-gray-500">
else
</span>
</div>
);
}
type ConditionLabelProps = {
condition: string | undefined;
type StatusLabelProps = {
status: string | undefined;
labelX: number;
labelY: number;
onSave: (value: string) => void;
};
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): ReactNode {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
function handleBadgeClick() {
setInputValue(condition || "");
setInputValue(status || "");
setIsOpen(true);
}
@@ -127,6 +112,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
}, [isOpen]);
const displayStatus = status?.trim() || null;
return (
<div
ref={containerRef}
@@ -142,11 +129,13 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
<span
className={cn(
"inline-block px-1 bg-white rounded text-[10px]",
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
displayStatus
? "border border-gray-300 text-black"
: "border border-dashed text-red-500",
)}
style={condition ? undefined : { borderColor: LACK_COLOR }}
style={displayStatus ? undefined : { borderColor: LACK_COLOR }}
>
if
{displayStatus ?? "status"}
</span>
</div>
{isOpen && (
@@ -155,7 +144,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
<input
type="text"
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
placeholder="输入条件"
placeholder="输入状态"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
@@ -174,14 +163,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
);
}
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
return siblings.length >= 2 && siblings[0].id === edgeId;
}
export function ConditionalEdge({
export function StatusEdge({
id,
source,
sourceX,
sourceY,
targetX,
@@ -190,7 +173,7 @@ export function ConditionalEdge({
targetPosition,
selected,
data,
}: EdgeProps<ConditionalEdgeType>): ReactNode {
}: EdgeProps<StatusEdgeType>): ReactNode {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
@@ -203,13 +186,11 @@ export function ConditionalEdge({
const flow = useReactFlow();
const model = useModel();
const allEdges = flow.getEdges();
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
const status = data?.status;
const condition = data?.condition;
function handleSave(value: string) {
model.startTransaction();
flow.updateEdgeData(id, { condition: value });
flow.updateEdgeData(id, { status: value });
requestAnimationFrame(model.endTransaction);
}
@@ -222,20 +203,11 @@ export function ConditionalEdge({
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
hasCondition={isElse ? null : !!condition}
hasStatus={!!status?.trim()}
selected={!!selected}
/>
<EdgeLabelRenderer>
{isElse ? (
<ElseBadge labelX={labelX} labelY={labelY} />
) : (
<ConditionLabel
condition={condition}
labelX={labelX}
labelY={labelY}
onSave={handleSave}
/>
)}
<StatusLabel status={status} labelX={labelX} labelY={labelY} onSave={handleSave} />
</EdgeLabelRenderer>
</>
);
@@ -269,7 +241,7 @@ export function GradientEdge({
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
hasCondition={null}
hasStatus={true}
selected={!!selected}
/>
);
@@ -65,12 +65,12 @@ export const edgesModel = define.model("edges", makeEdges, (set, get, model) =>
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
if (existingFromSource.length > 0) {
edge.type = "conditional";
edge.data = { condition: "" };
edge.type = "status";
edge.data = { status: "" };
const promoted = currentEdges.map((e) => {
if (e.source === normalized.source && e.type !== "conditional") {
return { ...e, type: "conditional" as const, data: { condition: "" } };
if (e.source === normalized.source && e.type !== "status") {
return { ...e, type: "status" as const, data: { status: "_" } };
}
return e;
});
@@ -34,21 +34,8 @@ export const handlers = define.memoize((use, model) => {
return node.type === "start" || node.type === "end";
}
function isFirstConditionalSibling(
edge: { id: string; source: string; type: string | null },
allEdges: { id: string; source: string; type: string | null }[],
): boolean {
if (edge.type !== "conditional") return false;
const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional");
return siblings.length >= 2 && siblings[0].id === edge.id;
}
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes }) => {
if (nodes.some(isProtectedNode)) return false;
if (edges.length > 0) {
const allEdges = use(edgesModel)[0];
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
}
model.startTransaction();
return true;
};
@@ -56,16 +43,14 @@ export const handlers = define.memoize((use, model) => {
if (deletedEdges.length > 0) {
const currentEdges = use(edgesModel)[0];
const sourcesToCheck = new Set(
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
deletedEdges.filter((e) => e.type === "status").map((e) => e.source),
);
if (sourcesToCheck.size > 0) {
let needsDowngrade = false;
const updatedEdges = currentEdges.map((e) => {
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
const siblings = currentEdges.filter(
(s) => s.source === e.source && s.type === "conditional",
);
if (!sourcesToCheck.has(e.source) || e.type !== "status") return e;
const siblings = currentEdges.filter((s) => s.source === e.source && s.type === "status");
if (siblings.length === 1) {
needsDowngrade = true;
const { data: _, ...rest } = e;
@@ -36,7 +36,7 @@ describe("transIn", () => {
});
it("4.3 Single step with END transition → edge to end node exists", () => {
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
const steps = [makeStep("A", [{ status: "_", target: "END" }])];
const { edges } = transIn(steps);
const endEdge = edges.find((e) => e.target === "end");
expect(endEdge).toBeDefined();
@@ -44,8 +44,8 @@ describe("transIn", () => {
it("4.4 Two steps with default transitions chain", () => {
const steps = [
makeStep("A", [{ condition: null, target: "B" }]),
makeStep("B", [{ condition: null, target: "END" }]),
makeStep("A", [{ status: "_", target: "B" }]),
makeStep("B", [{ status: "_", target: "END" }]),
];
const { edges } = transIn(steps);
// Should have start→A, A→B, B→end
@@ -53,15 +53,15 @@ describe("transIn", () => {
const nodeAId = edges.find((e) => e.source === "start")?.target;
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
expect(edges.find((e) => e.target === "end")).toBeDefined();
// No conditional edges
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
// No status edges for single default transitions
expect(edges.every((e) => e.type !== "status")).toBe(true);
});
it("4.5 Step with multiple transitions → conditional edges", () => {
it("4.5 Step with multiple transitions → status edges", () => {
const steps = [
makeStep("A", [
{ condition: null, target: "B" },
{ condition: "x>0", target: "C" },
{ status: "_", target: "B" },
{ status: "approved", target: "C" },
]),
makeStep("B", []),
makeStep("C", []),
@@ -69,23 +69,35 @@ describe("transIn", () => {
const { edges } = transIn(steps);
const nodeAId = edges.find((e) => e.source === "start")?.target;
const outEdges = edges.filter((e) => e.source === nodeAId);
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
// else-branch has empty condition
const elseEdge = outEdges.find(
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
expect(outEdges.every((e) => e.type === "status")).toBe(true);
});
it("4.5b Multiple transitions include expected status values", () => {
const steps = [
makeStep("A", [
{ status: "_", target: "B" },
{ status: "approved", target: "C" },
]),
makeStep("B", []),
makeStep("C", []),
];
const { edges } = transIn(steps);
const nodeAId = edges.find((e) => e.source === "start")?.target;
const outEdges = edges.filter((e) => e.source === nodeAId);
const defaultEdge = outEdges.find(
(e) => (e as { data?: { status?: string } }).data?.status === "_",
);
expect(elseEdge).toBeDefined();
// if-branch has condition
const ifEdge = outEdges.find(
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
expect(defaultEdge).toBeDefined();
const approvedEdge = outEdges.find(
(e) => (e as { data?: { status?: string } }).data?.status === "approved",
);
expect(ifEdge).toBeDefined();
expect(approvedEdge).toBeDefined();
});
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
const steps = [
makeStep("A", [{ condition: null, target: "END" }]),
makeStep("B", [{ condition: null, target: "END" }]),
makeStep("A", [{ status: "_", target: "END" }]),
makeStep("B", [{ status: "_", target: "END" }]),
];
const { edges } = transIn(steps);
// start→A and start→B; end has 2 incoming edges
@@ -95,8 +107,8 @@ describe("transIn", () => {
it("4.7 Same role name maps to same node id across steps", () => {
const steps = [
makeStep("A", [{ condition: null, target: "B" }]),
makeStep("B", [{ condition: null, target: "A" }]),
makeStep("A", [{ status: "_", target: "B" }]),
makeStep("B", [{ status: "_", target: "A" }]),
];
const { edges } = transIn(steps);
const aId = edges.find((e) => e.source === "start")?.target;
@@ -33,13 +33,13 @@ function defaultEdge(source: string, target: string): AnyWorkEdge {
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
}
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
function statusEdge(source: string, target: string, status: string): AnyWorkEdge {
return {
id: `${source}-${target}-cond`,
id: `${source}-${target}-status`,
source,
target,
type: "conditional" as const,
data: { condition },
type: "status" as const,
data: { status },
animated: true,
} as AnyWorkEdge;
}
@@ -76,36 +76,36 @@ describe("validateRoleNodes (via validate)", () => {
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
});
it("5.3 Empty condition on non-first conditional edge → error", () => {
it("5.3 Empty status on status edge → error", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
statusEdge("n1", "n2", "_"),
statusEdge("n1", "n3", ""), // empty status → error
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
expect(result.errors.some((e) => e.message.includes("状态值不能为空"))).toBe(true);
});
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
it("5.4 Mix of status and non-status outgoing → error", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
conditionalEdge("n1", "n2", "x>0"),
statusEdge("n1", "n2", "approved"),
defaultEdge("n1", "n3"), // mix → error
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true);
expect(result.errors.some((e) => e.message.includes("所有出边必须附带状态"))).toBe(true);
});
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
@@ -118,15 +118,15 @@ describe("validateRoleNodes (via validate)", () => {
expect(roleErrors).toHaveLength(0);
});
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
it("5.6 Valid role node (1 in, 2 status out with statuses) → no errors", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
conditionalEdge("n1", "n2", ""), // else-branch
conditionalEdge("n1", "n3", "x>0"), // if-branch
statusEdge("n1", "n2", "_"),
statusEdge("n1", "n3", "approved"),
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
@@ -1,4 +1,4 @@
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
import { uuid } from "../utils";
import type { WorkFlowStep } from "./type";
@@ -9,6 +9,7 @@ type Result = {
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
const DEFAULT_STATUS = "_";
function assignHandles(
indices: number[],
@@ -50,8 +51,8 @@ function buildNodeMap(
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
if (step.transitions.length <= 1) return step.transitions;
return [...step.transitions].sort((a, b) => {
if (a.condition === null && b.condition !== null) return -1;
if (a.condition !== null && b.condition === null) return 1;
if (a.status === DEFAULT_STATUS && b.status !== DEFAULT_STATUS) return -1;
if (a.status !== DEFAULT_STATUS && b.status === DEFAULT_STATUS) return 1;
return 0;
});
}
@@ -60,32 +61,32 @@ function buildStepEdges(
sourceId: string,
step: WorkFlowStep,
nameToId: Map<string, string>,
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
): { primaryEdges: AnyWorkEdge[]; statusEdges: AnyWorkEdge[] } {
const hasMultiple = step.transitions.length > 1;
const sorted = sortTransitions(step);
const elseEdges: AnyWorkEdge[] = [];
const ifEdges: AnyWorkEdge[] = [];
const primaryEdges: AnyWorkEdge[] = [];
const statusEdges: AnyWorkEdge[] = [];
for (let i = 0; i < sorted.length; i++) {
const t = sorted[i];
const targetId = nameToId.get(t.target);
if (!targetId) continue;
const edgeId = `e-${sourceId}-${targetId}-${i}`;
if (hasMultiple || t.condition !== null) {
const edge: ConditionalEdge = {
if (hasMultiple || t.status !== DEFAULT_STATUS) {
const edge: StatusEdge = {
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
type: "conditional",
data: { condition: t.condition ?? "" },
type: "status",
data: { status: t.status },
animated: true,
};
if (hasMultiple && i === 0) elseEdges.push(edge);
else ifEdges.push(edge);
if (hasMultiple && t.status === DEFAULT_STATUS) primaryEdges.push(edge);
else statusEdges.push(edge);
} else {
elseEdges.push({
primaryEdges.push({
id: edgeId,
source: sourceId,
target: targetId,
@@ -95,23 +96,23 @@ function buildStepEdges(
});
}
}
return { elseEdges, ifEdges };
return { primaryEdges, statusEdges };
}
function pushStepEdges(
edges: AnyWorkEdge[],
elseEdges: AnyWorkEdge[],
ifEdges: AnyWorkEdge[],
primaryEdges: AnyWorkEdge[],
statusEdges: AnyWorkEdge[],
idToOrder: Map<string, number>,
): void {
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
if (ifEdges.length > 0) {
const ifHandles = ["output-top", "output-bottom"] as const;
const sorted = [...ifEdges].sort(
for (const e of primaryEdges) edges.push({ ...e, sourceHandle: "output" });
if (statusEdges.length > 0) {
const statusHandles = ["output-top", "output-bottom"] as const;
const sorted = [...statusEdges].sort(
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
);
for (let i = 0; i < sorted.length; i++) {
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
edges.push({ ...sorted[i], sourceHandle: statusHandles[i % statusHandles.length] });
}
}
}
@@ -164,8 +165,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
for (const step of steps) {
const sourceId = nameToId.get(step.role.name) ?? "";
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
const { primaryEdges, statusEdges } = buildStepEdges(sourceId, step, nameToId);
pushStepEdges(edges, primaryEdges, statusEdges, idToOrder);
}
assignTargetHandles(edges, idToOrder);
@@ -1,6 +1,8 @@
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
import type { AnyWorkEdge, AnyWorkNode, StatusEdge, WorkNode } from "../type";
import type { WorkFlowStep, WorkFlowTransition } from "./type";
const DEFAULT_STATUS = "_";
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
const nodeMap = new Map<string, AnyWorkNode>();
for (const node of nodes) {
@@ -43,7 +45,7 @@ function traverse(
const roleNode = node as WorkNode<"role">;
const outEdges = outgoingEdges.get(nodeId) ?? [];
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
const transitions: WorkFlowTransition[] = outEdges.map((edge) => {
const targetNode = nodeMap.get(edge.target);
const target =
edge.target === "end"
@@ -52,13 +54,12 @@ function traverse(
? (targetNode as WorkNode<"role">).data.name
: edge.target;
let condition: string | null = null;
if (edge.type === "conditional") {
const isElse = outEdges.length >= 2 && index === 0;
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
}
const status =
edge.type === "status"
? ((edge as StatusEdge).data?.status ?? DEFAULT_STATUS)
: DEFAULT_STATUS;
return { target, condition };
return { target, status };
});
const { name, description, identity, prepare, execute, report } = roleNode.data;
@@ -1,4 +1,4 @@
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
export type ValidationError = {
nodeId: string | null;
@@ -91,10 +91,10 @@ function validateEndNode(
}
}
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
return conditionalEdges.slice(1).some((edge) => {
const cond = (edge as ConditionalEdge).data?.condition?.trim();
return !cond;
function hasEmptyStatusOnEdge(statusEdges: AnyWorkEdge[]): boolean {
return statusEdges.some((edge) => {
const status = (edge as StatusEdge).data?.status?.trim();
return !status;
});
}
@@ -113,11 +113,11 @@ function validateRoleNodeEdges(
}
if (outEdges.length <= 1) return;
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
if (conditionalEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
const statusEdges = outEdges.filter((e) => e.type === "status");
if (statusEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带状态" });
} else if (hasEmptyStatusOnEdge(statusEdges)) {
errors.push({ nodeId: node.id, message: "状态边的状态值不能为空" });
}
}
@@ -21,9 +21,9 @@ export type WorkNodeType = keyof NodeMap;
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
export type ConditionalEdgeData = AnyKeyBase & {
condition: string;
export type StatusEdgeData = AnyKeyBase & {
status: string;
};
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
export type AnyWorkEdge = ConditionalEdge | Edge;
export type StatusEdge = Edge<StatusEdgeData, "status">;
export type AnyWorkEdge = StatusEdge | Edge;
@@ -11,7 +11,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
execute: "制定详细的实施计划和步骤分解",
report: "输出结构化的计划文档,包含步骤列表和预期产出",
},
transitions: [{ target: "developer", condition: null }],
transitions: [{ target: "developer", status: "_" }],
},
{
role: {
@@ -22,7 +22,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
execute: "编写高质量的代码实现",
report: "输出变更文件列表和实现摘要",
},
transitions: [{ target: "reviewer", condition: null }],
transitions: [{ target: "reviewer", status: "_" }],
},
{
role: {
@@ -34,8 +34,8 @@ const DEFAULT_STEPS: WorkFlowSteps = [
report: "输出审查结果,包含 approved 状态和评审意见",
},
transitions: [
{ target: "END", condition: null },
{ target: "developer", condition: "steps[-1].output.approved = false" },
{ target: "END", status: "approved" },
{ target: "developer", status: "rejected" },
],
},
];
+11 -10
View File
@@ -1,12 +1,12 @@
# @uncaged/workflow-moderator
JSONata-based graph evaluator — determines the next role or `$END` with zero LLM cost.
Status-based graph evaluator — determines the next role or `$END` with zero LLM cost.
## Overview
The moderator (Layer 1) walks the workflow graph from the current role. For each outgoing transition it evaluates an optional JSONata condition against `ModeratorContext` (start prompt + prior step outputs). The first truthy transition wins; its target role and edge prompt are returned. When no transition matches, the workflow ends (`$END`).
The moderator (Layer 1) performs a status-based map lookup on the workflow graph. Given the last role and its output, it looks up `graph[lastRole][lastOutput.status]` to find the next `Target` (role + prompt template). The prompt is rendered via Mustache with `lastOutput` as the template context. For `$START`, the unit status `_` is used.
**Dependencies:** `@uncaged/workflow-protocol`, `jsonata`
**Dependencies:** `@uncaged/workflow-protocol`, `mustache`
## Installation
@@ -20,12 +20,13 @@ bun add @uncaged/workflow-moderator
```typescript
function evaluate(
workflow: WorkflowPayload,
context: ModeratorContext,
): Promise<Result<EvaluateResult, Error>>
graph: Record<string, Record<string, Target>>,
lastRole: string,
lastOutput: Record<string, unknown> & { status: string },
): Result<EvaluateResult, Error>
```
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the edge instruction for the agent.
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the rendered edge instruction for the agent.
### Types
@@ -42,9 +43,9 @@ The `Result<T, E>` type is local to this package (`{ ok: true; value: T } | { ok
```typescript
import { evaluate } from "@uncaged/workflow-moderator";
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
import type { Target } from "@uncaged/workflow-protocol";
const result = await evaluate(workflow, context);
const result = evaluate(graph, lastRole, lastOutput);
if (result.ok && result.value.role !== "$END") {
console.log(`Next role: ${result.value.role}, prompt: ${result.value.prompt}`);
}
@@ -55,6 +56,6 @@ if (result.ok && result.value.role !== "$END") {
```
src/
├── index.ts Public exports
├── evaluate.ts Graph walk + JSONata condition evaluation
├── evaluate.ts Status-based map lookup + Mustache prompt rendering
└── types.ts EvaluateResult, Result
```
@@ -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({
+9 -2
View File
@@ -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) {
+2 -9
View File
@@ -47,23 +47,16 @@ type RoleDefinition = {
frontmatter: CasRef;
};
type Transition = {
type Target = {
role: string;
condition: string | null;
prompt: string;
};
type ConditionDefinition = {
description: string;
expression: string;
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>;
graph: Record<string, Record<string, Target>>;
};
```
+3 -1
View File
@@ -60,7 +60,7 @@ export const START_NODE_SCHEMA: JSONSchema = {
export const STEP_NODE_SCHEMA: JSONSchema = {
title: "StepNode",
type: "object",
required: ["start", "prev", "role", "output", "detail", "agent"],
required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"],
properties: {
start: { type: "string", format: "cas_ref" },
prev: {
@@ -71,6 +71,8 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
detail: { type: "string", format: "cas_ref" },
agent: { type: "string" },
edgePrompt: { type: "string" },
startedAtMs: { type: "integer" },
completedAtMs: { type: "integer" },
},
additionalProperties: false,
};
+5
View File
@@ -14,6 +14,10 @@ export type StepRecord = {
agent: string;
/** Moderator edge prompt that led to this step. Missing in legacy nodes → "". */
edgePrompt: string;
/** Date.now() before agent spawn */
startedAtMs: number;
/** Date.now() after agent returns */
completedAtMs: number;
};
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
@@ -89,6 +93,7 @@ export type StepEntry = {
detail: CasRef;
agent: string;
timestamp: number;
durationMs: number;
};
/** uwf thread steps — start entry */