Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fca67e443 | |||
| 9b2460633c | |||
| dfb6fda06d | |||
| 827ff13c4a | |||
| 7a19ceca89 | |||
| 298b944169 | |||
| e40e41555b | |||
| 5a7f417899 | |||
| d00f9df2dd | |||
| ff959be3ef | |||
| f45563ee31 |
+61
-88
@@ -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." }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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>>
|
||||
```
|
||||
|
||||
@@ -22,6 +22,8 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
thesis:
|
||||
type: string
|
||||
keyPoints:
|
||||
@@ -30,14 +32,9 @@ roles:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [thesis, keyPoints]
|
||||
conditions: {}
|
||||
required: [$status, thesis, keyPoints]
|
||||
graph:
|
||||
$START:
|
||||
- role: "analyst"
|
||||
condition: null
|
||||
prompt: "Analyze the topic in the task and produce a structured summary with key points."
|
||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||
analyst:
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "Analysis complete. Finish the workflow."
|
||||
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
||||
|
||||
+15
-30
@@ -16,15 +16,16 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
conceded:
|
||||
type: boolean
|
||||
required: [argument, conceded]
|
||||
required: [$status, argument]
|
||||
for:
|
||||
description: "Argues for the proposition"
|
||||
goal: |
|
||||
@@ -40,38 +41,22 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
conceded:
|
||||
type: boolean
|
||||
required: [argument, conceded]
|
||||
conditions:
|
||||
againstConceded:
|
||||
description: "The against side conceded"
|
||||
expression: "$last('against').conceded = true"
|
||||
forConceded:
|
||||
description: "The for side conceded"
|
||||
expression: "$last('for').conceded = true"
|
||||
required: [$status, argument]
|
||||
graph:
|
||||
$START:
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Present your opening argument against the proposition."
|
||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||
against:
|
||||
- role: "$END"
|
||||
condition: "againstConceded"
|
||||
prompt: "The against side conceded. Debate over."
|
||||
- role: "for"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
||||
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
for:
|
||||
- role: "$END"
|
||||
condition: "forConceded"
|
||||
prompt: "The for side conceded. Debate over."
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
||||
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
|
||||
+14
-24
@@ -27,11 +27,13 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
repoPath:
|
||||
type: string
|
||||
plan:
|
||||
type: string
|
||||
required: [repoPath, plan]
|
||||
required: [$status, repoPath, plan]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. You implement code changes according to plans."
|
||||
@@ -50,13 +52,15 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
filesChanged:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
required: [filesChanged, summary]
|
||||
required: [$status, filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
||||
@@ -71,32 +75,18 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
approved:
|
||||
type: boolean
|
||||
$status:
|
||||
enum: ["approved", "rejected"]
|
||||
comments:
|
||||
type: string
|
||||
required: [approved, comments]
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
required: [$status, comments]
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null
|
||||
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
|
||||
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
|
||||
planner:
|
||||
- role: "developer"
|
||||
condition: null
|
||||
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
|
||||
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
|
||||
developer:
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
prompt: "Review the developer's implementation against the plan for correctness and quality."
|
||||
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "notApproved"
|
||||
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "The review passed. Complete the workflow."
|
||||
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
|
||||
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BootstrapCapableStore } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
StartEntry,
|
||||
@@ -18,6 +19,11 @@ import {
|
||||
walkChain,
|
||||
} from "./shared.js";
|
||||
|
||||
type TurnData = {
|
||||
index: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* List all steps in a thread (previously: thread steps)
|
||||
*/
|
||||
@@ -110,6 +116,108 @@ export async function cmdStepFork(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate step detail node from CAS store
|
||||
*/
|
||||
function loadStepDetail(store: BootstrapCapableStore, detailRef: CasRef): Record<string, unknown> {
|
||||
const detailNode = store.get(detailRef);
|
||||
if (detailNode === null) {
|
||||
fail(`detail node not found: ${detailRef}`);
|
||||
}
|
||||
return detailNode.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all turn nodes from CAS store and extract content
|
||||
*/
|
||||
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const turnData: TurnData[] = [];
|
||||
for (const turnRef of turns) {
|
||||
if (typeof turnRef !== "string") {
|
||||
continue;
|
||||
}
|
||||
const turnNode = store.get(turnRef as CasRef);
|
||||
if (turnNode === null) {
|
||||
continue;
|
||||
}
|
||||
const turn = turnNode.payload as Record<string, unknown>;
|
||||
if (typeof turn.content === "string") {
|
||||
turnData.push({
|
||||
index: typeof turn.index === "number" ? turn.index : turnData.length,
|
||||
content: turn.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
return turnData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select turns that fit within quota, working backwards from most recent
|
||||
*/
|
||||
function selectTurnsForQuota(turnData: TurnData[], availableQuota: number): TurnData[] {
|
||||
const selectedTurns: TurnData[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
for (let i = turnData.length - 1; i >= 0; i--) {
|
||||
const turn = turnData[i];
|
||||
if (turn === undefined) continue;
|
||||
|
||||
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
||||
const turnBlock = turnHeader + turn.content;
|
||||
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
|
||||
const addCost = turnBlock.length + separatorCost;
|
||||
|
||||
if (totalChars + addCost > availableQuota && selectedTurns.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
selectedTurns.unshift(turn);
|
||||
totalChars += addCost;
|
||||
}
|
||||
|
||||
return selectedTurns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble final markdown output from header and selected turns
|
||||
*/
|
||||
function formatStepMarkdown(
|
||||
stepHash: CasRef,
|
||||
role: string,
|
||||
agent: string,
|
||||
turnData: TurnData[],
|
||||
selectedTurns: TurnData[],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`# Step ${stepHash}`);
|
||||
parts.push("");
|
||||
parts.push(`**Role:** ${role}`);
|
||||
parts.push(`**Agent:** ${agent}`);
|
||||
|
||||
if (selectedTurns.length === 0) {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
const skippedCount = turnData.length - selectedTurns.length;
|
||||
if (skippedCount > 0) {
|
||||
parts.push("");
|
||||
parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`);
|
||||
}
|
||||
|
||||
for (const turn of selectedTurns) {
|
||||
parts.push("");
|
||||
parts.push(`## Turn ${turn.index + 1}`);
|
||||
parts.push("");
|
||||
parts.push(turn.content);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a step's agent turns as human-readable markdown with quota enforcement
|
||||
*/
|
||||
@@ -128,103 +236,21 @@ export async function cmdStepRead(
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
|
||||
// Build header section
|
||||
const parts: string[] = [];
|
||||
parts.push(`# Step ${stepHash}`);
|
||||
parts.push("");
|
||||
parts.push(`**Role:** ${payload.role}`);
|
||||
parts.push(`**Agent:** ${payload.agent}`);
|
||||
|
||||
// If no detail, return metadata only
|
||||
if (payload.detail === null) {
|
||||
return parts.join("\n");
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
}
|
||||
|
||||
// Load detail node
|
||||
const detailNode = uwf.store.get(payload.detail);
|
||||
if (detailNode === null) {
|
||||
fail(`detail node not found: ${payload.detail}`);
|
||||
}
|
||||
|
||||
const detail = detailNode.payload as Record<string, unknown>;
|
||||
const turns = detail.turns;
|
||||
|
||||
// If no turns array, return metadata only
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Load all turn nodes
|
||||
type TurnData = {
|
||||
index: number;
|
||||
content: string;
|
||||
};
|
||||
const turnData: TurnData[] = [];
|
||||
for (const turnRef of turns) {
|
||||
if (typeof turnRef !== "string") {
|
||||
continue;
|
||||
}
|
||||
const turnNode = uwf.store.get(turnRef as CasRef);
|
||||
if (turnNode === null) {
|
||||
continue;
|
||||
}
|
||||
const turn = turnNode.payload as Record<string, unknown>;
|
||||
if (typeof turn.content === "string") {
|
||||
turnData.push({
|
||||
index: typeof turn.index === "number" ? turn.index : turnData.length,
|
||||
content: turn.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
const detail = loadStepDetail(uwf.store, payload.detail);
|
||||
const turnData = loadTurnData(uwf.store, detail.turns);
|
||||
|
||||
if (turnData.length === 0) {
|
||||
return parts.join("\n");
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
}
|
||||
|
||||
// Calculate header length for quota accounting
|
||||
const headerSection = parts.join("\n");
|
||||
const headerLength = headerSection.length;
|
||||
const headerSection = formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
const BUFFER = 200;
|
||||
const availableQuota = quota - headerSection.length - BUFFER;
|
||||
const selectedTurns = selectTurnsForQuota(turnData, availableQuota);
|
||||
|
||||
// Select turns that fit within quota (working backwards from most recent)
|
||||
const BUFFER = 200; // Conservative buffer for structural overhead
|
||||
const availableQuota = quota - headerLength - BUFFER;
|
||||
|
||||
const selectedTurns: TurnData[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
for (let i = turnData.length - 1; i >= 0; i--) {
|
||||
const turn = turnData[i];
|
||||
if (turn === undefined) continue;
|
||||
|
||||
// Calculate formatted turn length
|
||||
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
||||
const turnBlock = turnHeader + turn.content;
|
||||
const separatorCost = selectedTurns.length > 0 ? 2 : 0; // "\n\n" between turns
|
||||
const addCost = turnBlock.length + separatorCost;
|
||||
|
||||
// Check quota - but always include at least one turn
|
||||
if (totalChars + addCost > availableQuota && selectedTurns.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
selectedTurns.unshift(turn);
|
||||
totalChars += addCost;
|
||||
}
|
||||
|
||||
// Add skip hint if not all turns fit
|
||||
const skippedCount = turnData.length - selectedTurns.length;
|
||||
if (skippedCount > 0) {
|
||||
parts.push("");
|
||||
parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`);
|
||||
}
|
||||
|
||||
// Add selected turns
|
||||
for (const turn of selectedTurns) {
|
||||
parts.push("");
|
||||
parts.push(`## Turn ${turn.index + 1}`);
|
||||
parts.push("");
|
||||
parts.push(turn.content);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -669,14 +675,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 +697,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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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. 当前状态与待完善项
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+24
-52
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,44 @@ describe("evaluate", () => {
|
||||
|
||||
test("mustache template rendering with simple fields", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
status: "_",
|
||||
$status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
test("mustache does not HTML-escape prompt content", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
$status: "rejected",
|
||||
comments: 'use <T> & "Result<T, E>" types',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
|
||||
});
|
||||
});
|
||||
|
||||
test("triple mustache also works for unescaped output", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
$status: "_",
|
||||
comments: "<script>alert(1)</script>",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing $status defaults to _ (unit routing)", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
@@ -84,7 +121,7 @@ describe("evaluate", () => {
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
status: "_",
|
||||
$status: "_",
|
||||
review: { comments: "refactor the handler" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -3,17 +3,27 @@ import mustache from "mustache";
|
||||
|
||||
import type { EvaluateResult, Result } from "./types.js";
|
||||
|
||||
// Disable HTML escaping — prompts are plain text, not HTML.
|
||||
mustache.escape = (text: string) => text;
|
||||
|
||||
const START_ROLE = "$START";
|
||||
const 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) {
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user