Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3f20fe7f3 |
+88
-61
@@ -22,17 +22,16 @@ 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. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
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)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
required: [$status]
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ready, insufficient_info]
|
||||
plan:
|
||||
type: string
|
||||
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."
|
||||
@@ -59,13 +58,14 @@ 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. Include branch name and worktree path in frontmatter."
|
||||
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [branch, worktree]
|
||||
status:
|
||||
type: string
|
||||
enum: [done, failed]
|
||||
required: [status]
|
||||
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,18 +95,13 @@ 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. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
|
||||
frontmatter:
|
||||
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]
|
||||
type: object
|
||||
properties:
|
||||
approved:
|
||||
type: boolean
|
||||
required: [approved]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
@@ -122,22 +117,14 @@ 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. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [passed, fix_code, fix_spec]
|
||||
required: [status]
|
||||
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."
|
||||
@@ -158,32 +145,72 @@ 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. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
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"
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
- role: "planner"
|
||||
condition: null
|
||||
prompt: "Analyze the issue and produce an implementation plan."
|
||||
planner:
|
||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||
- role: "$END"
|
||||
condition: "insufficientInfo"
|
||||
prompt: "Insufficient information to proceed; end the workflow."
|
||||
- role: "developer"
|
||||
condition: null
|
||||
prompt: "Implement the plan from the planner."
|
||||
developer:
|
||||
_: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||
- role: "$END"
|
||||
condition: "devFailed"
|
||||
prompt: "Development failed; end the workflow."
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
prompt: "Send the implementation to the reviewer."
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
||||
- role: "developer"
|
||||
condition: "rejected"
|
||||
prompt: "Reviewer rejected the implementation; fix the issues."
|
||||
- role: "tester"
|
||||
condition: null
|
||||
prompt: "Review passed; run tests on the implementation."
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
- 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."
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
- role: "developer"
|
||||
condition: "hookFailed"
|
||||
prompt: "Push hook failed; return to developer to fix."
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "Commit succeeded; complete the workflow."
|
||||
|
||||
@@ -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, status-based routing, and a directed graph. Stored as a CAS node, identified by its XXH64 hash. |
|
||||
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, conditions, and a routing 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** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
||||
| **Moderator** | JSONata-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 — Status-based graph evaluator
|
||||
workflow-moderator/ # @uncaged/workflow-moderator — JSONata 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, 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.
|
||||
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.
|
||||
|
||||
## 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 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.
|
||||
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.
|
||||
|
||||
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 Status-based graph evaluator
|
||||
workflow-moderator JSONata 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` | Status-based graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/README.md) |
|
||||
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata 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` | Status-based graph evaluator: given a routing graph, last role, and last output, returns the next role or `$END`. |
|
||||
| Moderator | `@uncaged/workflow-moderator` → `workflow-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, 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`. |
|
||||
| `mustache` | Template renderer for edge prompts (used by `workflow-moderator`). |
|
||||
| `jsonata` | JSONata expression evaluator (used by `workflow-moderator`). |
|
||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
||||
| `dotenv` | Loads `.env` files for API keys. |
|
||||
| `yaml` | YAML parse/stringify. |
|
||||
@@ -148,7 +148,8 @@ graph:
|
||||
Key properties:
|
||||
|
||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
||||
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
|
||||
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
|
||||
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
|
||||
- **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`
|
||||
|
||||
@@ -158,8 +159,8 @@ Each `uwf thread step` runs exactly one cycle: moderator → agent → extract.
|
||||
|
||||
```
|
||||
┌─→ Phase 1: MODERATOR
|
||||
│ Input: graph + lastRole + lastOutput
|
||||
│ Engine: Status-based map lookup against lastOutput.status
|
||||
│ Input: WorkflowPayload + ModeratorContext { start, steps[] }
|
||||
│ Engine: JSONata conditions evaluated against the graph
|
||||
│ Output: next role name | $END
|
||||
│
|
||||
│ Phase 2: AGENT
|
||||
@@ -206,7 +207,7 @@ type AgentContext = ModeratorContext & {
|
||||
|
||||
### Key properties
|
||||
|
||||
- **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.
|
||||
- **Moderator** — pure JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates `workflow.graph[currentRole]` transitions in order, returns first match.
|
||||
- **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.
|
||||
@@ -484,7 +485,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. |
|
||||
| **Status-based moderator** | Status-based map routing — `graph[role][status]` lookup against last output. No LLM cost for routing decisions. |
|
||||
| **JSONata moderator** | Declarative condition expressions evaluated against thread history. 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 / moderator 使用)
|
||||
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / JSONata 使用)
|
||||
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` + status-based moderator 负责。
|
||||
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + JSONata 负责。
|
||||
|
||||
#### Agent loop 必须能力
|
||||
|
||||
|
||||
+44
-23
@@ -75,7 +75,7 @@ uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
|
||||
**做的事:**
|
||||
1. 读链头 → 当前 StepNode(或 StartNode)
|
||||
2. 收集 thread 历史(遍历链)
|
||||
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
|
||||
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
|
||||
4. 若 END → 归档 thread,输出最后链头,退出
|
||||
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
||||
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
||||
@@ -199,21 +199,29 @@ payload:
|
||||
```
|
||||
|
||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `graph` — `Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
|
||||
- Status 来自上一个 role 输出的 `status` 字段,`$START` 用 `_` 作为初始 status
|
||||
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
|
||||
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
||||
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
||||
- `condition` 引用 conditions 中的 key,`null` = fallback
|
||||
- 按数组顺序求值,第一个匹配的 transition 胜出
|
||||
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
||||
|
||||
Moderator 的求值逻辑:
|
||||
JSONata 表达式的求值上下文:
|
||||
|
||||
```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)
|
||||
```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" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target。
|
||||
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
|
||||
|
||||
#### `StartNode`(Thread 起点)
|
||||
|
||||
@@ -342,7 +350,7 @@ OPENROUTER_API_KEY=sk-or-...
|
||||
```
|
||||
packages/
|
||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
|
||||
├── workflow-moderator/ # @uncaged/workflow-moderator — Status-based moderator 引擎
|
||||
├── workflow-moderator/ # @uncaged/workflow-moderator — JSONata 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
|
||||
@@ -359,7 +367,7 @@ packages/
|
||||
|
||||
## 4. 关键数据类型
|
||||
|
||||
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||
|
||||
### 4.1 公共类型
|
||||
|
||||
@@ -370,7 +378,7 @@ type CasRef = string;
|
||||
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||
type ThreadId = string;
|
||||
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||
type StepRecord = {
|
||||
role: string;
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
||||
@@ -391,16 +399,22 @@ type RoleDefinition = {
|
||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
};
|
||||
|
||||
type Target = {
|
||||
type Transition = {
|
||||
role: string; // 目标 role 名 或 "$END"
|
||||
prompt: string; // Mustache 模板,渲染时注入 lastOutput
|
||||
condition: string | null; // 引用 conditions 中的 key,null = fallback
|
||||
};
|
||||
|
||||
type ConditionDefinition = {
|
||||
description: string;
|
||||
expression: string; // JSONata expression
|
||||
};
|
||||
|
||||
type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>;
|
||||
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
|
||||
conditions: Record<string, ConditionDefinition>;
|
||||
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
|
||||
};
|
||||
```
|
||||
|
||||
@@ -418,14 +432,20 @@ type StepNodePayload = StepRecord & {
|
||||
};
|
||||
```
|
||||
|
||||
### 4.4 Moderator 求值
|
||||
### 4.4 JSONata 求值上下文
|
||||
|
||||
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
|
||||
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
|
||||
|
||||
```typescript
|
||||
// graph[lastRole][lastOutput.status] → Target { role, prompt }
|
||||
// $START 角色使用 "_" 作为初始 status
|
||||
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
|
||||
/** JSONata 上下文中的 step — output 被展开 */
|
||||
type StepContext = Omit<StepRecord, "output"> & {
|
||||
output: unknown; // 展开后的 CAS 节点内容,非 hash
|
||||
};
|
||||
|
||||
type ModeratorContext = {
|
||||
start: StartNodePayload;
|
||||
steps: StepContext[]; // 从旧到新
|
||||
};
|
||||
```
|
||||
|
||||
### 4.5 CLI 输出
|
||||
@@ -514,5 +534,6 @@ StepNodePayload ──extends──→ StepRecord ←──maps to──→ Step
|
||||
│
|
||||
└── start.workflow → WorkflowPayload
|
||||
├── roles: Record<name, RoleDefinition>
|
||||
└── graph: Record<role, Record<status, Target>>
|
||||
├── conditions: Record<name, JSONata>
|
||||
└── graph: Record<role, Transition[]>
|
||||
```
|
||||
|
||||
@@ -22,8 +22,6 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
thesis:
|
||||
type: string
|
||||
keyPoints:
|
||||
@@ -32,9 +30,14 @@ roles:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [$status, thesis, keyPoints]
|
||||
required: [thesis, keyPoints]
|
||||
conditions: {}
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||
- role: "analyst"
|
||||
condition: null
|
||||
prompt: "Analyze the topic in the task and produce a structured summary with key points."
|
||||
analyst:
|
||||
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "Analysis complete. Finish the workflow."
|
||||
|
||||
+30
-15
@@ -16,16 +16,15 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [$status, argument]
|
||||
conceded:
|
||||
type: boolean
|
||||
required: [argument, conceded]
|
||||
for:
|
||||
description: "Argues for the proposition"
|
||||
goal: |
|
||||
@@ -41,22 +40,38 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [$status, argument]
|
||||
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"
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Present your opening argument against the proposition."
|
||||
against:
|
||||
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
||||
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
- role: "$END"
|
||||
condition: "againstConceded"
|
||||
prompt: "The against side conceded. Debate over."
|
||||
- role: "for"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
for:
|
||||
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
||||
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
- role: "$END"
|
||||
condition: "forConceded"
|
||||
prompt: "The for side conceded. Debate over."
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
|
||||
+24
-14
@@ -27,13 +27,11 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
repoPath:
|
||||
type: string
|
||||
plan:
|
||||
type: string
|
||||
required: [$status, repoPath, plan]
|
||||
required: [repoPath, plan]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. You implement code changes according to plans."
|
||||
@@ -52,15 +50,13 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
filesChanged:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
required: [$status, filesChanged, summary]
|
||||
required: [filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
||||
@@ -75,18 +71,32 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["approved", "rejected"]
|
||||
approved:
|
||||
type: boolean
|
||||
comments:
|
||||
type: string
|
||||
required: [$status, comments]
|
||||
required: [approved, comments]
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
|
||||
- role: "planner"
|
||||
condition: null
|
||||
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
|
||||
planner:
|
||||
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
|
||||
- role: "developer"
|
||||
condition: null
|
||||
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
|
||||
developer:
|
||||
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
prompt: "Review the developer's implementation against the plan for correctness and quality."
|
||||
reviewer:
|
||||
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
|
||||
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
|
||||
- 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."
|
||||
|
||||
@@ -81,18 +81,17 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
||||
});
|
||||
|
||||
test("committer frontmatter schema should be oneOf with $status discriminant", async () => {
|
||||
test("committer frontmatter schema should require success field", 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?.oneOf).toBeDefined();
|
||||
const committedVariant = frontmatter.oneOf.find(
|
||||
(v: any) => v.properties?.["$status"]?.const === "committed",
|
||||
);
|
||||
expect(committedVariant).toBeDefined();
|
||||
expect(committedVariant.required).toContain("$status");
|
||||
expect(frontmatter?.type).toBe("object");
|
||||
expect(frontmatter?.properties?.success).toBeDefined();
|
||||
expect(frontmatter?.properties?.success?.type).toBe("boolean");
|
||||
expect(frontmatter?.required).toContain("success");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,8 +144,6 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
// Read step with large quota
|
||||
@@ -229,8 +227,6 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
// Read step with limited quota (700 chars)
|
||||
@@ -308,8 +304,6 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
// Read step with minimal quota (1 char)
|
||||
@@ -363,8 +357,6 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
// Read step - should return metadata only (no error)
|
||||
@@ -439,8 +431,6 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
// Read step - should return metadata only (no error)
|
||||
@@ -515,8 +505,6 @@ describe("step read", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
// Read step
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdStepList } from "../commands/step.js";
|
||||
import { cmdThreadRead } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import { saveThreadsIndex } from "../store.js";
|
||||
|
||||
// ── schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const TURN_SCHEMA = {
|
||||
title: "hermes-turn",
|
||||
type: "object" as const,
|
||||
required: ["index", "role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" as const },
|
||||
role: { type: "string" as const },
|
||||
content: { type: "string" as const },
|
||||
toolCalls: {
|
||||
anyOf: [
|
||||
{ type: "array" as const, items: { type: "object" as const } },
|
||||
{ type: "null" as const },
|
||||
],
|
||||
},
|
||||
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const DETAIL_SCHEMA = {
|
||||
title: "hermes-detail",
|
||||
type: "object" as const,
|
||||
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||
properties: {
|
||||
sessionId: { type: "string" as const },
|
||||
model: { type: "string" as const },
|
||||
duration: { type: "integer" as const },
|
||||
turnCount: { type: "integer" as const },
|
||||
turns: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const, format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||
await bootstrap(store);
|
||||
const [turn, detail] = await Promise.all([
|
||||
putSchema(store, TURN_SCHEMA),
|
||||
putSchema(store, DETAIL_SCHEMA),
|
||||
]);
|
||||
return { turn, detail };
|
||||
}
|
||||
|
||||
// ── fixture ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── 1. Protocol types (compile-time) ─────────────────────────────────────────
|
||||
|
||||
describe("protocol types", () => {
|
||||
test("StepRecord has startedAtMs and completedAtMs as required fields", () => {
|
||||
// Type-level test: this block compiles only if fields exist and are number
|
||||
const record: import("@uncaged/workflow-protocol").StepRecord = {
|
||||
role: "test",
|
||||
output: "hash1" as CasRef,
|
||||
detail: "hash2" as CasRef,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
};
|
||||
expect(record.startedAtMs).toBe(1000);
|
||||
expect(record.completedAtMs).toBe(2000);
|
||||
});
|
||||
|
||||
test("StepEntry has durationMs as required field", () => {
|
||||
const entry: import("@uncaged/workflow-protocol").StepEntry = {
|
||||
hash: "hash" as CasRef,
|
||||
role: "test",
|
||||
output: {},
|
||||
detail: "hash2" as CasRef,
|
||||
agent: "uwf-test",
|
||||
timestamp: 123,
|
||||
durationMs: 5000,
|
||||
};
|
||||
expect(entry.durationMs).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. JSON Schema ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("StepNode JSON schema", () => {
|
||||
test("schema requires startedAtMs and completedAtMs", () => {
|
||||
const required = STEP_NODE_SCHEMA.required as string[];
|
||||
expect(required).toContain("startedAtMs");
|
||||
expect(required).toContain("completedAtMs");
|
||||
});
|
||||
|
||||
test("schema defines timing fields as integer", () => {
|
||||
const props = STEP_NODE_SCHEMA.properties as Record<string, { type: string }>;
|
||||
expect(props.startedAtMs.type).toBe("integer");
|
||||
expect(props.completedAtMs.type).toBe("integer");
|
||||
});
|
||||
|
||||
test("StepNode with timing fields passes CAS validation", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: "placeholder0000" as CasRef,
|
||||
prompt: "test",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.text, "output text");
|
||||
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "s1",
|
||||
model: "m1",
|
||||
duration: 100,
|
||||
turnCount: 0,
|
||||
turns: [],
|
||||
});
|
||||
|
||||
// Should succeed — valid timing fields
|
||||
const hash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. step list — durationMs computed ───────────────────────────────────────
|
||||
|
||||
describe("step list timing", () => {
|
||||
test("step list includes durationMs = completedAtMs - startedAtMs", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "test",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.text, "output");
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "s1",
|
||||
model: "m1",
|
||||
duration: 100,
|
||||
turnCount: 0,
|
||||
turns: [],
|
||||
});
|
||||
|
||||
const startedAt = 1716600000000;
|
||||
const completedAt = 1716600003500;
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: startedAt,
|
||||
completedAtMs: completedAt,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const result = await cmdStepList(tmpDir, threadId);
|
||||
const stepEntries = result.steps.slice(1); // skip start entry
|
||||
expect(stepEntries).toHaveLength(1);
|
||||
|
||||
const step = stepEntries[0] as import("@uncaged/workflow-protocol").StepEntry;
|
||||
expect(step.durationMs).toBe(3500);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. thread read — duration in header ──────────────────────────────────────
|
||||
|
||||
describe("thread read timing", () => {
|
||||
test("thread read header includes Duration", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "Do work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: "placeholder0000" as CasRef,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "go" } },
|
||||
worker: { _: { role: "$END", prompt: "" } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "test task",
|
||||
});
|
||||
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Done.",
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "s1",
|
||||
model: "m1",
|
||||
duration: 100,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
const outputHash = await store.put(schemas.text, "output");
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1716600000000,
|
||||
completedAtMs: 1716600042000,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ3" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
|
||||
expect(markdown).toContain("**Duration:** 42.0s");
|
||||
});
|
||||
|
||||
test("thread read shows sub-second duration as ms", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "Do work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: "placeholder0000" as CasRef,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "go" } },
|
||||
worker: { _: { role: "$END", prompt: "" } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "test",
|
||||
});
|
||||
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Done.",
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "s1",
|
||||
model: "m1",
|
||||
duration: 100,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
const outputHash = await store.put(schemas.text, "output");
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1716600000000,
|
||||
completedAtMs: 1716600000350,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
|
||||
expect(markdown).toContain("**Duration:** 350ms");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 6. Breaking change — old data without timing fails ───────────────────────
|
||||
|
||||
describe("breaking change", () => {
|
||||
test("StepNode schema rejects payload without timing fields", () => {
|
||||
const required = STEP_NODE_SCHEMA.required as string[];
|
||||
// Both fields must be in the required array
|
||||
expect(required).toContain("startedAtMs");
|
||||
expect(required).toContain("completedAtMs");
|
||||
|
||||
// Payload without timing fields would fail schema validation
|
||||
// because the schema marks them as required
|
||||
const payloadWithoutTiming = {
|
||||
start: "hash1",
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: "hash2",
|
||||
detail: "hash3",
|
||||
agent: "uwf-test",
|
||||
edgePrompt: "",
|
||||
};
|
||||
// Verify the payload is missing required fields
|
||||
expect(payloadWithoutTiming).not.toHaveProperty("startedAtMs");
|
||||
expect(payloadWithoutTiming).not.toHaveProperty("completedAtMs");
|
||||
});
|
||||
});
|
||||
@@ -141,8 +141,6 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
@@ -223,8 +221,6 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: step1DetailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const step2Content = generateContent(600, "Second");
|
||||
@@ -249,8 +245,6 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: step2DetailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||
@@ -334,8 +328,6 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
@@ -346,8 +338,8 @@ describe("thread read --quota flag", () => {
|
||||
// Set tight quota with --start flag
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
|
||||
|
||||
// Quota must be reasonably enforced (allow ~260 char tolerance for structure)
|
||||
expect(markdown.length).toBeLessThanOrEqual(860);
|
||||
// Quota must be reasonably enforced (allow ~210 char tolerance for structure)
|
||||
expect(markdown.length).toBeLessThanOrEqual(810);
|
||||
|
||||
// Should contain thread header
|
||||
expect(markdown).toMatch(/# Thread/);
|
||||
@@ -413,8 +405,6 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||
@@ -490,8 +480,6 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
@@ -571,8 +559,6 @@ describe("thread read --quota flag", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
|
||||
@@ -139,8 +139,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-claude-code",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000001" as ThreadId;
|
||||
@@ -216,8 +214,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-claude-code",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000002" as ThreadId;
|
||||
@@ -278,8 +274,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
@@ -289,8 +283,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||
@@ -343,8 +335,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||
@@ -397,8 +387,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: missingDetailRef,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||
@@ -451,8 +439,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000006" as ThreadId;
|
||||
@@ -525,8 +511,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
@@ -536,8 +520,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
@@ -547,8 +529,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000007" as ThreadId;
|
||||
@@ -627,8 +607,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000008" as ThreadId;
|
||||
@@ -683,8 +661,6 @@ describe("thread read XML tag isolation", () => {
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
})) as CasRef;
|
||||
steps.push(step);
|
||||
prev = step;
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
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,37 +20,23 @@ async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
return { storageRoot, store, schemas };
|
||||
}
|
||||
|
||||
function makeMinimalPayload(name: string, description: string): WorkflowPayload {
|
||||
return {
|
||||
name,
|
||||
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");
|
||||
const payload: WorkflowPayload = {
|
||||
name,
|
||||
description: "Test workflow",
|
||||
roles: {},
|
||||
graph: {},
|
||||
};
|
||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||
}
|
||||
|
||||
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
|
||||
const payload = makeMinimalPayload(
|
||||
const payload: WorkflowPayload = {
|
||||
name,
|
||||
version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||
);
|
||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||
roles: {},
|
||||
graph: {},
|
||||
};
|
||||
const yaml = stringify(payload);
|
||||
return yaml;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { BootstrapCapableStore } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
StartEntry,
|
||||
@@ -19,11 +18,6 @@ import {
|
||||
walkChain,
|
||||
} from "./shared.js";
|
||||
|
||||
type TurnData = {
|
||||
index: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* List all steps in a thread (previously: thread steps)
|
||||
*/
|
||||
@@ -58,7 +52,6 @@ export async function cmdStepList(
|
||||
detail: item.payload.detail ?? null,
|
||||
agent: item.payload.agent,
|
||||
timestamp: item.timestamp,
|
||||
durationMs: item.payload.completedAtMs - item.payload.startedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,108 +110,6 @@ 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
|
||||
*/
|
||||
@@ -237,21 +128,103 @@ 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 formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
const detail = loadStepDetail(uwf.store, payload.detail);
|
||||
const turnData = loadTurnData(uwf.store, detail.turns);
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (turnData.length === 0) {
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
const headerSection = formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
const BUFFER = 200;
|
||||
const availableQuota = quota - headerSection.length - BUFFER;
|
||||
const selectedTurns = selectTurnsForQuota(turnData, availableQuota);
|
||||
// Calculate header length for quota accounting
|
||||
const headerSection = parts.join("\n");
|
||||
const headerLength = headerSection.length;
|
||||
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
|
||||
// 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");
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
|
||||
import { validateWorkflow } from "../validate-semantic.js";
|
||||
import {
|
||||
type ChainState,
|
||||
collectOrderedSteps,
|
||||
@@ -170,11 +169,6 @@ 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);
|
||||
@@ -566,25 +560,14 @@ function selectByQuota(
|
||||
return { selected, skippedCount: candidates.length - selected.length };
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSec = Math.round(seconds % 60);
|
||||
return `${minutes}m${remainingSec}s`;
|
||||
}
|
||||
|
||||
function formatStepHeader(stepNum: number, item: OrderedStepItem): string {
|
||||
const ts = new Date(item.timestamp)
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d+Z$/, "");
|
||||
const durationMs = item.payload.completedAtMs - item.payload.startedAtMs;
|
||||
const duration = formatDuration(durationMs);
|
||||
return [
|
||||
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts} | **Duration:** ${duration}`,
|
||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -686,16 +669,14 @@ function formatThreadReadMarkdown(options: {
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
type EvaluateLastOutput = Record<string, unknown>;
|
||||
|
||||
const STATUS_KEY = "$status";
|
||||
type EvaluateLastOutput = Record<string, unknown> & { status: string };
|
||||
|
||||
function resolveEvaluateArgs(
|
||||
uwf: UwfStore,
|
||||
chain: ChainState,
|
||||
): { lastRole: string; lastOutput: EvaluateLastOutput } {
|
||||
if (chain.headIsStart) {
|
||||
return { lastRole: START_ROLE, lastOutput: { [STATUS_KEY]: "_" } };
|
||||
return { lastRole: START_ROLE, lastOutput: { status: "_" } };
|
||||
}
|
||||
|
||||
const lastStep = chain.stepsNewestFirst[0];
|
||||
@@ -708,10 +689,11 @@ 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,
|
||||
lastOutput: { ...base, status },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
|
||||
import { validateWorkflow } from "../validate-semantic.js";
|
||||
|
||||
export type WorkflowOrigin = "local" | "global";
|
||||
|
||||
@@ -137,11 +136,6 @@ 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);
|
||||
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
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,9 +16,7 @@ function isRoleDefinition(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
const frontmatter = value.frontmatter;
|
||||
const frontmatterOk =
|
||||
isRecord(frontmatter) &&
|
||||
(typeof frontmatter.type === "string" || Array.isArray(frontmatter.oneOf));
|
||||
const frontmatterOk = isRecord(frontmatter) && typeof frontmatter.type === "string";
|
||||
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 (no discriminant — flat merge)", () => {
|
||||
test("lists union of fields from a oneOf schema", () => {
|
||||
const schema = {
|
||||
oneOf: [
|
||||
{
|
||||
@@ -101,71 +101,12 @@ 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,109 +166,14 @@ 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function buildOutputFormatInstruction(schema: JSONSchema): string {
|
||||
// Check for discriminated union (oneOf with shared discriminant)
|
||||
const unionKey = Array.isArray(schema.oneOf)
|
||||
? "oneOf"
|
||||
: Array.isArray(schema.anyOf)
|
||||
? "anyOf"
|
||||
: null;
|
||||
|
||||
if (unionKey !== null) {
|
||||
const variants = schema[unionKey] as JSONSchema[];
|
||||
const discriminant = detectDiscriminant(variants);
|
||||
|
||||
if (discriminant !== null && variants.length > 1) {
|
||||
const variantBlocks = variants.map((v) => buildVariantBlock(v, discriminant)).join("\n\n");
|
||||
|
||||
return `## Deliverable Format
|
||||
|
||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work.
|
||||
|
||||
Choose ONE of the following variants based on your outcome:
|
||||
|
||||
${variantBlocks}
|
||||
|
||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||
Output ONLY the fields listed for your chosen variant. Do not add extra fields that are not specified in the schema.
|
||||
|
||||
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat object schema fallback
|
||||
const properties = extractSchemaProperties(schema);
|
||||
const yamlExample = buildYamlExampleBlock(properties);
|
||||
const fieldList = buildFieldList(properties);
|
||||
|
||||
@@ -128,8 +128,6 @@ async function buildHistory(
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
edgePrompt: step.edgePrompt ?? "",
|
||||
startedAtMs: step.startedAtMs,
|
||||
completedAtMs: step.completedAtMs,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,8 +59,6 @@ async function writeStepNode(options: {
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
edgePrompt: string;
|
||||
startedAtMs: number;
|
||||
completedAtMs: number;
|
||||
}): Promise<CasRef> {
|
||||
const payload: StepNodePayload = {
|
||||
start: options.startHash,
|
||||
@@ -70,8 +68,6 @@ async function writeStepNode(options: {
|
||||
detail: options.detailHash,
|
||||
agent: options.agentName,
|
||||
edgePrompt: options.edgePrompt,
|
||||
startedAtMs: options.startedAtMs,
|
||||
completedAtMs: options.completedAtMs,
|
||||
};
|
||||
const hash = await options.store.put(options.schemas.stepNode, payload);
|
||||
const node = options.store.get(hash);
|
||||
@@ -98,8 +94,6 @@ async function persistStep(options: {
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
startedAtMs: number;
|
||||
completedAtMs: number;
|
||||
}): Promise<CasRef> {
|
||||
const { store, schemas, chain, headHash } = options.ctx.meta;
|
||||
return writeStepNode({
|
||||
@@ -112,8 +106,6 @@ async function persistStep(options: {
|
||||
detailHash: options.detailHash,
|
||||
agentName: options.agentName,
|
||||
edgePrompt: options.ctx.edgePrompt,
|
||||
startedAtMs: options.startedAtMs,
|
||||
completedAtMs: options.completedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +127,6 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
|
||||
}
|
||||
|
||||
const startedAtMs = Date.now();
|
||||
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
|
||||
|
||||
// Preserve the primary detail from the first run — it contains the full
|
||||
@@ -165,14 +156,12 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
`Raw output (first 500 chars): ${agentResult.output.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
const completedAtMs = Date.now();
|
||||
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
detailHash: primaryDetailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
});
|
||||
|
||||
process.stdout.write(`${stepHash}\n`);
|
||||
|
||||
@@ -123,7 +123,7 @@ type RoleNodeData = {
|
||||
|
||||
**边类型**:
|
||||
- `default`(GradientEdge)→ 渐变色边(绿→蓝),节点仅有一条出边时使用
|
||||
- `status`(StatusEdge)→ 带 status 标签的渐变色边,节点有多条出边时使用
|
||||
- `conditional`(ConditionalEdge)→ 带条件标签的渐变色边,节点有多条出边时使用
|
||||
|
||||
**边渲染特性**:
|
||||
- 渐变色:SVG linearGradient,从 source 端绿色(#10b981)到 target 端蓝色(#3b82f6)
|
||||
@@ -234,7 +234,7 @@ Model 提供事务机制:
|
||||
```
|
||||
ReactFlow
|
||||
├─ nodeTypes: { start: NodeStart, end: NodeEnd, role: NodeRole }
|
||||
└─ edgeTypes: { default: GradientEdge, status: StatusEdge }
|
||||
└─ edgeTypes: { default: GradientEdge, conditional: ConditionalEdge }
|
||||
```
|
||||
|
||||
`NodeRole` 显示角色名(data.name),使用 teal 色系图标和标签。Handle 分蓝色(in)和绿色(out)两种颜色。
|
||||
@@ -324,11 +324,12 @@ type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>; // 角色定义(4 段式:identity/prepare/execute/report)
|
||||
graph: Record<string, Record<string, Target>>; // status-based 路由图
|
||||
conditions: Record<string, ConditionDefinition>; // JSONata 条件表达式
|
||||
graph: Record<string, Transition[]>; // 角色间的转移图
|
||||
};
|
||||
```
|
||||
|
||||
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Target`。外部(CLI/server)负责 `WorkflowPayload` ↔ `WorkFlowSteps` 的转换。
|
||||
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Transition`。外部(CLI/server)负责 `WorkflowPayload` ↔ `WorkFlowSteps` 的转换。
|
||||
|
||||
## 11. 当前状态与待完善项
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function createApi() {
|
||||
transitions: t.Array(
|
||||
t.Object({
|
||||
target: t.String(),
|
||||
status: t.String(),
|
||||
condition: t.Union([t.String(), t.Null()]),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import YAML from "yaml";
|
||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||
|
||||
@@ -11,12 +11,17 @@ 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 statusMap = payload.graph[roleName] ?? {};
|
||||
const transitions: WorkFlowTransition[] = Object.entries(statusMap).map(([status, target]) => ({
|
||||
target: target.role === "$END" ? "END" : target.role,
|
||||
status,
|
||||
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,
|
||||
}));
|
||||
|
||||
steps.push({
|
||||
@@ -37,7 +42,11 @@ function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
||||
|
||||
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
const graph: Record<string, Record<string, Target>> = {};
|
||||
const conditions: WorkflowPayload["conditions"] = {};
|
||||
const graph: Record<string, Transition[]> = {};
|
||||
|
||||
const expressionToName = new Map<string, string>();
|
||||
let condIdx = 0;
|
||||
|
||||
for (const step of steps) {
|
||||
const r = step.role;
|
||||
@@ -50,28 +59,43 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
frontmatter: "",
|
||||
};
|
||||
|
||||
const statusMap: Record<string, Target> = {};
|
||||
for (const t of step.transitions) {
|
||||
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 targetRole = t.target === "END" ? "$END" : t.target;
|
||||
statusMap[t.status] = {
|
||||
return {
|
||||
role: targetRole,
|
||||
condition: condName,
|
||||
prompt: `Transition to ${targetRole}.`,
|
||||
};
|
||||
}
|
||||
graph[r.name] = statusMap;
|
||||
});
|
||||
|
||||
graph[r.name] = transitions;
|
||||
}
|
||||
|
||||
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, graph };
|
||||
return { name, description, roles, conditions, graph };
|
||||
}
|
||||
|
||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||
@@ -101,6 +125,7 @@ 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;
|
||||
status: string;
|
||||
condition: string | null;
|
||||
};
|
||||
|
||||
export type WorkFlowStep = {
|
||||
|
||||
+52
-24
@@ -6,10 +6,10 @@ import {
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { Check } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import type { StatusEdge as StatusEdgeType } from "../type.ts";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
|
||||
const SOURCE_COLOR = "#10b981";
|
||||
const TARGET_COLOR = "#3b82f6";
|
||||
@@ -23,7 +23,7 @@ function GradientPath({
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
hasStatus,
|
||||
hasCondition,
|
||||
selected,
|
||||
}: {
|
||||
id: string;
|
||||
@@ -32,11 +32,11 @@ function GradientPath({
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
hasStatus: boolean;
|
||||
hasCondition: boolean | null;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const gradientId = `gradient-${id}`;
|
||||
const showLack = !hasStatus;
|
||||
const showLack = hasCondition === false;
|
||||
const strokeStyle = selected
|
||||
? { stroke: "#f59e0b", strokeWidth: 2 }
|
||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||
@@ -68,20 +68,35 @@ function GradientPath({
|
||||
);
|
||||
}
|
||||
|
||||
type StatusLabelProps = {
|
||||
status: string | undefined;
|
||||
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;
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
onSave: (value: string) => void;
|
||||
};
|
||||
|
||||
function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): ReactNode {
|
||||
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function handleBadgeClick() {
|
||||
setInputValue(status || "");
|
||||
setInputValue(condition || "");
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
@@ -112,8 +127,6 @@ function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): Reac
|
||||
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
||||
}, [isOpen]);
|
||||
|
||||
const displayStatus = status?.trim() || null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -129,13 +142,11 @@ function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): Reac
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block px-1 bg-white rounded text-[10px]",
|
||||
displayStatus
|
||||
? "border border-gray-300 text-black"
|
||||
: "border border-dashed text-red-500",
|
||||
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
|
||||
)}
|
||||
style={displayStatus ? undefined : { borderColor: LACK_COLOR }}
|
||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
||||
>
|
||||
{displayStatus ?? "status"}
|
||||
if
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
@@ -144,7 +155,7 @@ function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): Reac
|
||||
<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}
|
||||
@@ -163,8 +174,14 @@ function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): Reac
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusEdge({
|
||||
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({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
@@ -173,7 +190,7 @@ export function StatusEdge({
|
||||
targetPosition,
|
||||
selected,
|
||||
data,
|
||||
}: EdgeProps<StatusEdgeType>): ReactNode {
|
||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -186,11 +203,13 @@ export function StatusEdge({
|
||||
const flow = useReactFlow();
|
||||
const model = useModel();
|
||||
|
||||
const status = data?.status;
|
||||
const allEdges = flow.getEdges();
|
||||
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
|
||||
|
||||
const condition = data?.condition;
|
||||
function handleSave(value: string) {
|
||||
model.startTransaction();
|
||||
flow.updateEdgeData(id, { status: value });
|
||||
flow.updateEdgeData(id, { condition: value });
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
@@ -203,11 +222,20 @@ export function StatusEdge({
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasStatus={!!status?.trim()}
|
||||
hasCondition={isElse ? null : !!condition}
|
||||
selected={!!selected}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<StatusLabel status={status} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||
{isElse ? (
|
||||
<ElseBadge labelX={labelX} labelY={labelY} />
|
||||
) : (
|
||||
<ConditionLabel
|
||||
condition={condition}
|
||||
labelX={labelX}
|
||||
labelY={labelY}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
@@ -241,7 +269,7 @@ export function GradientEdge({
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasStatus={true}
|
||||
hasCondition={null}
|
||||
selected={!!selected}
|
||||
/>
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GradientEdge, StatusEdge } from "./status";
|
||||
import { ConditionalEdge, GradientEdge } from "./conditional";
|
||||
|
||||
export const edgeTypes = {
|
||||
status: StatusEdge,
|
||||
conditional: ConditionalEdge,
|
||||
default: GradientEdge,
|
||||
};
|
||||
|
||||
@@ -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 = "status";
|
||||
edge.data = { status: "" };
|
||||
edge.type = "conditional";
|
||||
edge.data = { condition: "" };
|
||||
|
||||
const promoted = currentEdges.map((e) => {
|
||||
if (e.source === normalized.source && e.type !== "status") {
|
||||
return { ...e, type: "status" as const, data: { status: "_" } };
|
||||
if (e.source === normalized.source && e.type !== "conditional") {
|
||||
return { ...e, type: "conditional" as const, data: { condition: "" } };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
@@ -34,8 +34,21 @@ export const handlers = define.memoize((use, model) => {
|
||||
return node.type === "start" || node.type === "end";
|
||||
}
|
||||
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes }) => {
|
||||
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 }) => {
|
||||
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;
|
||||
};
|
||||
@@ -43,14 +56,16 @@ 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 === "status").map((e) => e.source),
|
||||
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
|
||||
);
|
||||
|
||||
if (sourcesToCheck.size > 0) {
|
||||
let needsDowngrade = false;
|
||||
const updatedEdges = currentEdges.map((e) => {
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== "status") return e;
|
||||
const siblings = currentEdges.filter((s) => s.source === e.source && s.type === "status");
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
|
||||
const siblings = currentEdges.filter(
|
||||
(s) => s.source === e.source && s.type === "conditional",
|
||||
);
|
||||
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", [{ status: "_", target: "END" }])];
|
||||
const steps = [makeStep("A", [{ condition: null, 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", [{ status: "_", target: "B" }]),
|
||||
makeStep("B", [{ status: "_", target: "END" }]),
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, 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 status edges for single default transitions
|
||||
expect(edges.every((e) => e.type !== "status")).toBe(true);
|
||||
// No conditional edges
|
||||
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
|
||||
});
|
||||
|
||||
it("4.5 Step with multiple transitions → status edges", () => {
|
||||
it("4.5 Step with multiple transitions → conditional edges", () => {
|
||||
const steps = [
|
||||
makeStep("A", [
|
||||
{ status: "_", target: "B" },
|
||||
{ status: "approved", target: "C" },
|
||||
{ condition: null, target: "B" },
|
||||
{ condition: "x>0", target: "C" },
|
||||
]),
|
||||
makeStep("B", []),
|
||||
makeStep("C", []),
|
||||
@@ -69,35 +69,23 @@ 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 === "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(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(defaultEdge).toBeDefined();
|
||||
const approvedEdge = outEdges.find(
|
||||
(e) => (e as { data?: { status?: string } }).data?.status === "approved",
|
||||
expect(elseEdge).toBeDefined();
|
||||
// if-branch has condition
|
||||
const ifEdge = outEdges.find(
|
||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
|
||||
);
|
||||
expect(approvedEdge).toBeDefined();
|
||||
expect(ifEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ status: "_", target: "END" }]),
|
||||
makeStep("B", [{ status: "_", target: "END" }]),
|
||||
makeStep("A", [{ condition: null, target: "END" }]),
|
||||
makeStep("B", [{ condition: null, target: "END" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
// start→A and start→B; end has 2 incoming edges
|
||||
@@ -107,8 +95,8 @@ describe("transIn", () => {
|
||||
|
||||
it("4.7 Same role name maps to same node id across steps", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ status: "_", target: "B" }]),
|
||||
makeStep("B", [{ status: "_", target: "A" }]),
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, 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 statusEdge(source: string, target: string, status: string): AnyWorkEdge {
|
||||
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
|
||||
return {
|
||||
id: `${source}-${target}-status`,
|
||||
id: `${source}-${target}-cond`,
|
||||
source,
|
||||
target,
|
||||
type: "status" as const,
|
||||
data: { status },
|
||||
type: "conditional" as const,
|
||||
data: { condition },
|
||||
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 status on status edge → error", () => {
|
||||
it("5.3 Empty condition on non-first conditional 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"),
|
||||
statusEdge("n1", "n2", "_"),
|
||||
statusEdge("n1", "n3", ""), // empty status → error
|
||||
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
|
||||
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → 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 status and non-status outgoing → error", () => {
|
||||
it("5.4 Mix of conditional and non-conditional 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"),
|
||||
statusEdge("n1", "n2", "approved"),
|
||||
conditionalEdge("n1", "n2", "x>0"),
|
||||
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 status out with statuses) → no errors", () => {
|
||||
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → 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"),
|
||||
statusEdge("n1", "n2", "_"),
|
||||
statusEdge("n1", "n3", "approved"),
|
||||
conditionalEdge("n1", "n2", ""), // else-branch
|
||||
conditionalEdge("n1", "n3", "x>0"), // if-branch
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
||||
import { uuid } from "../utils";
|
||||
import type { WorkFlowStep } from "./type";
|
||||
|
||||
@@ -9,7 +9,6 @@ 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[],
|
||||
@@ -51,8 +50,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.status === DEFAULT_STATUS && b.status !== DEFAULT_STATUS) return -1;
|
||||
if (a.status !== DEFAULT_STATUS && b.status === DEFAULT_STATUS) return 1;
|
||||
if (a.condition === null && b.condition !== null) return -1;
|
||||
if (a.condition !== null && b.condition === null) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
@@ -61,32 +60,32 @@ function buildStepEdges(
|
||||
sourceId: string,
|
||||
step: WorkFlowStep,
|
||||
nameToId: Map<string, string>,
|
||||
): { primaryEdges: AnyWorkEdge[]; statusEdges: AnyWorkEdge[] } {
|
||||
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
|
||||
const hasMultiple = step.transitions.length > 1;
|
||||
const sorted = sortTransitions(step);
|
||||
const primaryEdges: AnyWorkEdge[] = [];
|
||||
const statusEdges: AnyWorkEdge[] = [];
|
||||
const elseEdges: AnyWorkEdge[] = [];
|
||||
const ifEdges: 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.status !== DEFAULT_STATUS) {
|
||||
const edge: StatusEdge = {
|
||||
if (hasMultiple || t.condition !== null) {
|
||||
const edge: ConditionalEdge = {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
type: "status",
|
||||
data: { status: t.status },
|
||||
type: "conditional",
|
||||
data: { condition: t.condition ?? "" },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultiple && t.status === DEFAULT_STATUS) primaryEdges.push(edge);
|
||||
else statusEdges.push(edge);
|
||||
if (hasMultiple && i === 0) elseEdges.push(edge);
|
||||
else ifEdges.push(edge);
|
||||
} else {
|
||||
primaryEdges.push({
|
||||
elseEdges.push({
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
@@ -96,23 +95,23 @@ function buildStepEdges(
|
||||
});
|
||||
}
|
||||
}
|
||||
return { primaryEdges, statusEdges };
|
||||
return { elseEdges, ifEdges };
|
||||
}
|
||||
|
||||
function pushStepEdges(
|
||||
edges: AnyWorkEdge[],
|
||||
primaryEdges: AnyWorkEdge[],
|
||||
statusEdges: AnyWorkEdge[],
|
||||
elseEdges: AnyWorkEdge[],
|
||||
ifEdges: AnyWorkEdge[],
|
||||
idToOrder: Map<string, number>,
|
||||
): void {
|
||||
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(
|
||||
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(
|
||||
(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: statusHandles[i % statusHandles.length] });
|
||||
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,8 +164,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||
const { primaryEdges, statusEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||
pushStepEdges(edges, primaryEdges, statusEdges, idToOrder);
|
||||
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
||||
}
|
||||
|
||||
assignTargetHandles(edges, idToOrder);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { AnyWorkEdge, AnyWorkNode, StatusEdge, WorkNode } from "../type";
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, 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) {
|
||||
@@ -45,7 +43,7 @@ function traverse(
|
||||
const roleNode = node as WorkNode<"role">;
|
||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
||||
|
||||
const transitions: WorkFlowTransition[] = outEdges.map((edge) => {
|
||||
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
|
||||
const targetNode = nodeMap.get(edge.target);
|
||||
const target =
|
||||
edge.target === "end"
|
||||
@@ -54,12 +52,13 @@ function traverse(
|
||||
? (targetNode as WorkNode<"role">).data.name
|
||||
: edge.target;
|
||||
|
||||
const status =
|
||||
edge.type === "status"
|
||||
? ((edge as StatusEdge).data?.status ?? DEFAULT_STATUS)
|
||||
: DEFAULT_STATUS;
|
||||
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);
|
||||
}
|
||||
|
||||
return { target, status };
|
||||
return { target, condition };
|
||||
});
|
||||
|
||||
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
||||
|
||||
export type ValidationError = {
|
||||
nodeId: string | null;
|
||||
@@ -91,10 +91,10 @@ function validateEndNode(
|
||||
}
|
||||
}
|
||||
|
||||
function hasEmptyStatusOnEdge(statusEdges: AnyWorkEdge[]): boolean {
|
||||
return statusEdges.some((edge) => {
|
||||
const status = (edge as StatusEdge).data?.status?.trim();
|
||||
return !status;
|
||||
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
|
||||
return conditionalEdges.slice(1).some((edge) => {
|
||||
const cond = (edge as ConditionalEdge).data?.condition?.trim();
|
||||
return !cond;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,11 +113,11 @@ function validateRoleNodeEdges(
|
||||
}
|
||||
if (outEdges.length <= 1) return;
|
||||
|
||||
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: "状态边的状态值不能为空" });
|
||||
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: "条件边的条件表达式不能为空" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 StatusEdgeData = AnyKeyBase & {
|
||||
status: string;
|
||||
export type ConditionalEdgeData = AnyKeyBase & {
|
||||
condition: string;
|
||||
};
|
||||
|
||||
export type StatusEdge = Edge<StatusEdgeData, "status">;
|
||||
export type AnyWorkEdge = StatusEdge | Edge;
|
||||
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
|
||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
||||
|
||||
@@ -11,7 +11,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
execute: "制定详细的实施计划和步骤分解",
|
||||
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
||||
},
|
||||
transitions: [{ target: "developer", status: "_" }],
|
||||
transitions: [{ target: "developer", condition: null }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
@@ -22,7 +22,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
execute: "编写高质量的代码实现",
|
||||
report: "输出变更文件列表和实现摘要",
|
||||
},
|
||||
transitions: [{ target: "reviewer", status: "_" }],
|
||||
transitions: [{ target: "reviewer", condition: null }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
@@ -34,8 +34,8 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
report: "输出审查结果,包含 approved 状态和评审意见",
|
||||
},
|
||||
transitions: [
|
||||
{ target: "END", status: "approved" },
|
||||
{ target: "developer", status: "rejected" },
|
||||
{ target: "END", condition: null },
|
||||
{ target: "developer", condition: "steps[-1].output.approved = false" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# @uncaged/workflow-moderator
|
||||
|
||||
Status-based graph evaluator — determines the next role or `$END` with zero LLM cost.
|
||||
JSONata-based graph evaluator — determines the next role or `$END` with zero LLM cost.
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
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`).
|
||||
|
||||
**Dependencies:** `@uncaged/workflow-protocol`, `mustache`
|
||||
**Dependencies:** `@uncaged/workflow-protocol`, `jsonata`
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -20,13 +20,12 @@ bun add @uncaged/workflow-moderator
|
||||
|
||||
```typescript
|
||||
function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: Record<string, unknown> & { status: string },
|
||||
): Result<EvaluateResult, Error>
|
||||
workflow: WorkflowPayload,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<EvaluateResult, Error>>
|
||||
```
|
||||
|
||||
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.
|
||||
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the edge instruction for the agent.
|
||||
|
||||
### Types
|
||||
|
||||
@@ -43,9 +42,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 { Target } from "@uncaged/workflow-protocol";
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
const result = evaluate(graph, lastRole, lastOutput);
|
||||
const result = await evaluate(workflow, context);
|
||||
if (result.ok && result.value.role !== "$END") {
|
||||
console.log(`Next role: ${result.value.role}, prompt: ${result.value.prompt}`);
|
||||
}
|
||||
@@ -56,6 +55,6 @@ if (result.ok && result.value.role !== "$END") {
|
||||
```
|
||||
src/
|
||||
├── index.ts Public exports
|
||||
├── evaluate.ts Status-based map lookup + Mustache prompt rendering
|
||||
├── evaluate.ts Graph walk + JSONata condition evaluation
|
||||
└── 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,44 +65,7 @@ describe("evaluate", () => {
|
||||
|
||||
test("mustache template rendering with simple fields", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
$status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
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", {
|
||||
status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
@@ -121,7 +84,7 @@ describe("evaluate", () => {
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
$status: "_",
|
||||
status: "_",
|
||||
review: { comments: "refactor the handler" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -3,27 +3,17 @@ 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>;
|
||||
|
||||
const STATUS_KEY = "$status";
|
||||
type LastOutput = Record<string, unknown> & { status: string };
|
||||
|
||||
export function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: LastOutput,
|
||||
): Result<EvaluateResult, Error> {
|
||||
const status =
|
||||
lastRole === START_ROLE
|
||||
? UNIT_STATUS
|
||||
: typeof lastOutput[STATUS_KEY] === "string"
|
||||
? (lastOutput[STATUS_KEY] as string)
|
||||
: UNIT_STATUS;
|
||||
const status = lastRole === START_ROLE ? UNIT_STATUS : lastOutput.status;
|
||||
|
||||
const roleTargets = graph[lastRole];
|
||||
if (roleTargets === undefined) {
|
||||
|
||||
@@ -47,16 +47,23 @@ type RoleDefinition = {
|
||||
frontmatter: CasRef;
|
||||
};
|
||||
|
||||
type Target = {
|
||||
type Transition = {
|
||||
role: string;
|
||||
condition: string | null;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
type ConditionDefinition = {
|
||||
description: string;
|
||||
expression: string;
|
||||
};
|
||||
|
||||
type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>;
|
||||
graph: Record<string, Record<string, Target>>;
|
||||
conditions: Record<string, ConditionDefinition>;
|
||||
graph: Record<string, Transition[]>;
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export const START_NODE_SCHEMA: JSONSchema = {
|
||||
export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||
title: "StepNode",
|
||||
type: "object",
|
||||
required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"],
|
||||
required: ["start", "prev", "role", "output", "detail", "agent"],
|
||||
properties: {
|
||||
start: { type: "string", format: "cas_ref" },
|
||||
prev: {
|
||||
@@ -71,8 +71,6 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||
detail: { type: "string", format: "cas_ref" },
|
||||
agent: { type: "string" },
|
||||
edgePrompt: { type: "string" },
|
||||
startedAtMs: { type: "integer" },
|
||||
completedAtMs: { type: "integer" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
@@ -14,10 +14,6 @@ export type StepRecord = {
|
||||
agent: string;
|
||||
/** Moderator edge prompt that led to this step. Missing in legacy nodes → "". */
|
||||
edgePrompt: string;
|
||||
/** Date.now() before agent spawn */
|
||||
startedAtMs: number;
|
||||
/** Date.now() after agent returns */
|
||||
completedAtMs: number;
|
||||
};
|
||||
|
||||
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
|
||||
@@ -93,7 +89,6 @@ export type StepEntry = {
|
||||
detail: CasRef;
|
||||
agent: string;
|
||||
timestamp: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
/** uwf thread steps — start entry */
|
||||
|
||||
Reference in New Issue
Block a user