Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 298b944169 | |||
| e40e41555b | |||
| 5a7f417899 | |||
| d00f9df2dd | |||
| ff959be3ef | |||
| f45563ee31 | |||
| 2b8cd99100 | |||
| 1ca13e02b2 | |||
| 3146832d1b | |||
| 64f929c10d | |||
| 1ec32ae0fd | |||
| f851a087f2 | |||
| 984e6ae56d | |||
| 92f3b36b10 | |||
| a4677f8adb | |||
| 9ab6291a41 | |||
| 50a4db72b1 | |||
| dfdf0ac073 | |||
| c2c849df7e | |||
| 39f6ae692b | |||
| eb027e70f4 | |||
| 8fbbbce07e |
+26
-64
@@ -95,13 +95,14 @@ roles:
|
|||||||
|
|
||||||
Only review standards compliance. Do NOT test functionality.
|
Only review standards compliance. Do NOT test functionality.
|
||||||
If rejecting, you MUST explain the specific reason in your output.
|
If rejecting, you MUST explain the specific reason in your output.
|
||||||
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
|
output: "Explain your decision with specific file/line references. Frontmatter must include: status (approved or rejected)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
approved:
|
status:
|
||||||
type: boolean
|
type: string
|
||||||
required: [approved]
|
enum: [approved, rejected]
|
||||||
|
required: [status]
|
||||||
tester:
|
tester:
|
||||||
description: "Functional correctness verification"
|
description: "Functional correctness verification"
|
||||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||||
@@ -137,77 +138,38 @@ roles:
|
|||||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||||
3. Push the branch: `git push -u origin <branch-name>`
|
3. Push the branch: `git push -u origin <branch-name>`
|
||||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||||
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
|
4. On push success: create a PR via `tea pr create --repo uncaged/workflow --title "..." --description "..."`
|
||||||
|
- The `--repo` flag is required to work in worktree directories (fixes #474 "path segment [0] is empty" error)
|
||||||
|
- If working on a different repo, extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||||
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||||
|
- On tea failure: capture stderr/stdout, log the error clearly, include PR details (title, description, branch) for manual creation, and mark success=false
|
||||||
5. After PR creation, clean up the worktree:
|
5. After PR creation, clean up the worktree:
|
||||||
- `cd ~/repos/workflow`
|
- `cd ~/repos/workflow`
|
||||||
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
||||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
output: "Include PR URL on success or error log on failure. Frontmatter must include: status (committed or hook_failed)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
success:
|
status:
|
||||||
type: boolean
|
type: string
|
||||||
required: [success]
|
enum: [committed, hook_failed]
|
||||||
conditions:
|
required: [status]
|
||||||
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:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
- role: "planner"
|
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||||
condition: null
|
|
||||||
prompt: "Analyze the issue and produce an implementation plan."
|
|
||||||
planner:
|
planner:
|
||||||
- role: "$END"
|
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||||
condition: "insufficientInfo"
|
ready: { role: "developer", prompt: "Implement the plan from the planner." }
|
||||||
prompt: "Insufficient information to proceed; end the workflow."
|
|
||||||
- role: "developer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Implement the plan from the planner."
|
|
||||||
developer:
|
developer:
|
||||||
- role: "$END"
|
failed: { role: "$END", prompt: "Development failed; end the workflow." }
|
||||||
condition: "devFailed"
|
done: { role: "reviewer", prompt: "Send the implementation to the reviewer." }
|
||||||
prompt: "Development failed; end the workflow."
|
|
||||||
- role: "reviewer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Send the implementation to the reviewer."
|
|
||||||
reviewer:
|
reviewer:
|
||||||
- role: "developer"
|
rejected: { role: "developer", prompt: "Reviewer rejected the implementation; fix the issues." }
|
||||||
condition: "rejected"
|
approved: { role: "tester", prompt: "Review passed; run tests on the implementation." }
|
||||||
prompt: "Reviewer rejected the implementation; fix the issues."
|
|
||||||
- role: "tester"
|
|
||||||
condition: null
|
|
||||||
prompt: "Review passed; run tests on the implementation."
|
|
||||||
tester:
|
tester:
|
||||||
- role: "developer"
|
fix_code: { role: "developer", prompt: "Tests found code issues; return to developer." }
|
||||||
condition: "fixCode"
|
fix_spec: { role: "planner", prompt: "Tests found spec issues; return to planner." }
|
||||||
prompt: "Tests found code issues; return to developer."
|
passed: { role: "committer", prompt: "Tests passed; commit and push the changes." }
|
||||||
- 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:
|
committer:
|
||||||
- role: "developer"
|
hook_failed: { role: "developer", prompt: "Push hook failed; return to developer to fix." }
|
||||||
condition: "hookFailed"
|
committed: { role: "$END", prompt: "Commit succeeded; complete the workflow." }
|
||||||
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 |
|
| Concept | What it is |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, conditions, and a routing graph. Stored as a CAS node, identified by its XXH64 hash. |
|
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, status-based routing, and a directed graph. Stored as a CAS node, identified by its XXH64 hash. |
|
||||||
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
|
| **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`. |
|
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
|
||||||
| **Moderator** | JSONata-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
| **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
||||||
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
|
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
|
||||||
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
|
| **CAS** | Content-Addressed Storage via `@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. |
|
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
|
||||||
@@ -23,7 +23,7 @@ workflow/
|
|||||||
packages/
|
packages/
|
||||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
||||||
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
||||||
workflow-moderator/ # @uncaged/workflow-moderator — JSONata graph evaluator
|
workflow-moderator/ # @uncaged/workflow-moderator — Status-based graph evaluator
|
||||||
workflow-agent-kit/ # @uncaged/workflow-agent-kit — createAgent factory, context builder, extract pipeline
|
workflow-agent-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)
|
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
||||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
|
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# @uncaged/workflow
|
# @uncaged/workflow
|
||||||
|
|
||||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, JSONata routing conditions, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, status-based routing, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates JSONata conditions to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
|
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates status-based routing to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
|
||||||
|
|
||||||
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
|
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
|
Layer 1 — Shared infra
|
||||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
workflow-util Encoding, IDs, logging, frontmatter, paths
|
||||||
workflow-moderator JSONata graph evaluator
|
workflow-moderator Status-based graph evaluator
|
||||||
|
|
||||||
Layer 2 — Agent framework
|
Layer 2 — Agent framework
|
||||||
workflow-agent-kit createAgent factory, context builder, extract pipeline
|
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) |
|
| `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-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
|
||||||
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/README.md) |
|
| `workflow-moderator` | `@uncaged/workflow-moderator` | Status-based graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/README.md) |
|
||||||
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-agent-kit/README.md) |
|
| `workflow-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-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
|
||||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
||||||
@@ -62,16 +62,16 @@ See [docs/architecture.md](docs/architecture.md) for the full design — three-p
|
|||||||
uwf setup
|
uwf setup
|
||||||
|
|
||||||
# 2. Register a workflow from YAML
|
# 2. Register a workflow from YAML
|
||||||
uwf workflow put examples/solve-issue.yaml
|
uwf workflow add examples/solve-issue.yaml
|
||||||
|
|
||||||
# 3. Start a thread (creates head pointer; does not execute)
|
# 3. Start a thread (creates head pointer; does not execute)
|
||||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||||
|
|
||||||
# 4. Execute steps (one at a time, until done)
|
# 4. Execute steps (one at a time, until done)
|
||||||
uwf thread step <thread-id>
|
uwf thread exec <thread-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `-c, --count <number>` on `thread step` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
@@ -79,8 +79,9 @@ Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
|||||||
|
|
||||||
| Group | Commands |
|
| Group | Commands |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| **thread** | `start`, `step`, `show`, `list`, `kill`, `steps`, `read`, `fork`, `step-details` |
|
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
|
||||||
| **workflow** | `put`, `show`, `list` |
|
| **step** | `list`, `show`, `read`, `fork` |
|
||||||
|
| **workflow** | `add`, `show`, `list` |
|
||||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
||||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
||||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
| **skill** | `cli` — print markdown reference of all uwf commands |
|
||||||
|
|||||||
@@ -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`. |
|
| 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. |
|
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
|
||||||
| Moderator | `@uncaged/workflow-moderator` → `workflow-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
|
| Moderator | `@uncaged/workflow-moderator` → `workflow-moderator` | Status-based graph evaluator: given a routing graph, last role, and last output, returns the next role or `$END`. |
|
||||||
| Agent framework | `@uncaged/workflow-agent-kit` → `workflow-agent-kit` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
| Agent 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. |
|
| Agent: Hermes | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
|
||||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
|
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
|
||||||
@@ -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` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
||||||
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
|
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
|
||||||
| `jsonata` | JSONata expression evaluator (used by `workflow-moderator`). |
|
| `mustache` | Template renderer for edge prompts (used by `workflow-moderator`). |
|
||||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
||||||
| `dotenv` | Loads `.env` files for API keys. |
|
| `dotenv` | Loads `.env` files for API keys. |
|
||||||
| `yaml` | YAML parse/stringify. |
|
| `yaml` | YAML parse/stringify. |
|
||||||
@@ -148,8 +148,7 @@ graph:
|
|||||||
Key properties:
|
Key properties:
|
||||||
|
|
||||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
||||||
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
|
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
|
||||||
- **`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 agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
||||||
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
|
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
|
||||||
|
|
||||||
@@ -159,8 +158,8 @@ Each `uwf thread step` runs exactly one cycle: moderator → agent → extract.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─→ Phase 1: MODERATOR
|
┌─→ Phase 1: MODERATOR
|
||||||
│ Input: WorkflowPayload + ModeratorContext { start, steps[] }
|
│ Input: graph + lastRole + lastOutput
|
||||||
│ Engine: JSONata conditions evaluated against the graph
|
│ Engine: Status-based map lookup against lastOutput.status
|
||||||
│ Output: next role name | $END
|
│ Output: next role name | $END
|
||||||
│
|
│
|
||||||
│ Phase 2: AGENT
|
│ Phase 2: AGENT
|
||||||
@@ -207,7 +206,7 @@ type AgentContext = ModeratorContext & {
|
|||||||
|
|
||||||
### Key properties
|
### Key properties
|
||||||
|
|
||||||
- **Moderator** — pure JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates `workflow.graph[currentRole]` transitions in order, returns first match.
|
- **Moderator** — pure status-based map lookup; no LLM call, no I/O beyond CAS reads. Looks up `graph[lastRole][lastOutput.status]` to get the next target.
|
||||||
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
|
- **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.
|
- **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.
|
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
|
||||||
@@ -485,7 +484,7 @@ Binary: `uwf`
|
|||||||
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
|
| **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. |
|
| **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. |
|
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
|
||||||
| **JSONata moderator** | Declarative condition expressions evaluated against thread history. No LLM cost for routing decisions. |
|
| **Status-based moderator** | Status-based map routing — `graph[role][status]` lookup against last output. No LLM cost for routing decisions. |
|
||||||
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
|
| **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. |
|
| **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. |
|
| **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`
|
1. 从 `threads.yaml[threadId]` 取 `headHash`
|
||||||
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
|
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
|
||||||
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / JSONata 使用)
|
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
|
||||||
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
|
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
|
||||||
|
|
||||||
#### Role definition 来源
|
#### Role definition 来源
|
||||||
@@ -572,7 +572,7 @@ Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes
|
|||||||
| P1 | `grep` | 搜索符号/引用 |
|
| P1 | `grep` | 搜索符号/引用 |
|
||||||
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
|
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
|
||||||
|
|
||||||
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + JSONata 负责。
|
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
|
||||||
|
|
||||||
#### Agent loop 必须能力
|
#### Agent loop 必须能力
|
||||||
|
|
||||||
|
|||||||
+23
-44
@@ -75,7 +75,7 @@ uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
|
|||||||
**做的事:**
|
**做的事:**
|
||||||
1. 读链头 → 当前 StepNode(或 StartNode)
|
1. 读链头 → 当前 StepNode(或 StartNode)
|
||||||
2. 收集 thread 历史(遍历链)
|
2. 收集 thread 历史(遍历链)
|
||||||
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
|
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
|
||||||
4. 若 END → 归档 thread,输出最后链头,退出
|
4. 若 END → 归档 thread,输出最后链头,退出
|
||||||
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
||||||
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
||||||
@@ -199,29 +199,21 @@ payload:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||||
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
- `graph` — `Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
|
||||||
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
- Status 来自上一个 role 输出的 `status` 字段,`$START` 用 `_` 作为初始 status
|
||||||
- `condition` 引用 conditions 中的 key,`null` = fallback
|
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
|
||||||
- 按数组顺序求值,第一个匹配的 transition 胜出
|
|
||||||
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
||||||
|
|
||||||
JSONata 表达式的求值上下文:
|
Moderator 的求值逻辑:
|
||||||
|
|
||||||
```jsonc
|
```typescript
|
||||||
{
|
evaluate(graph, lastRole, lastOutput) → { role, prompt }
|
||||||
"start": { // StartNode 信息
|
// 1. status = lastRole === "$START" ? "_" : lastOutput.status
|
||||||
"workflow": "4KNM2PXR3B1QW",
|
// 2. target = graph[lastRole][status]
|
||||||
"prompt": "Fix the login bug..."
|
// 3. prompt = mustache.render(target.prompt, lastOutput)
|
||||||
},
|
|
||||||
"steps": [ // 所有已完成 steps,从旧到新
|
|
||||||
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
|
|
||||||
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
|
|
||||||
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
|
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target。
|
||||||
|
|
||||||
#### `StartNode`(Thread 起点)
|
#### `StartNode`(Thread 起点)
|
||||||
|
|
||||||
@@ -350,7 +342,7 @@ OPENROUTER_API_KEY=sk-or-...
|
|||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
|
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
|
||||||
├── workflow-moderator/ # @uncaged/workflow-moderator — JSONata moderator 引擎
|
├── workflow-moderator/ # @uncaged/workflow-moderator — Status-based moderator 引擎
|
||||||
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
|
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
|
||||||
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
||||||
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
||||||
@@ -367,7 +359,7 @@ packages/
|
|||||||
|
|
||||||
## 4. 关键数据类型
|
## 4. 关键数据类型
|
||||||
|
|
||||||
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||||
|
|
||||||
### 4.1 公共类型
|
### 4.1 公共类型
|
||||||
|
|
||||||
@@ -378,7 +370,7 @@ type CasRef = string;
|
|||||||
/** Thread ID — ULID, 26-char Crockford Base32 */
|
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||||
type ThreadId = string;
|
type ThreadId = string;
|
||||||
|
|
||||||
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
|
||||||
type StepRecord = {
|
type StepRecord = {
|
||||||
role: string;
|
role: string;
|
||||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
||||||
@@ -399,22 +391,16 @@ type RoleDefinition = {
|
|||||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||||
};
|
};
|
||||||
|
|
||||||
type Transition = {
|
type Target = {
|
||||||
role: string; // 目标 role 名 或 "$END"
|
role: string; // 目标 role 名 或 "$END"
|
||||||
condition: string | null; // 引用 conditions 中的 key,null = fallback
|
prompt: string; // Mustache 模板,渲染时注入 lastOutput
|
||||||
};
|
|
||||||
|
|
||||||
type ConditionDefinition = {
|
|
||||||
description: string;
|
|
||||||
expression: string; // JSONata expression
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkflowPayload = {
|
type WorkflowPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
roles: Record<string, RoleDefinition>;
|
roles: Record<string, RoleDefinition>;
|
||||||
conditions: Record<string, ConditionDefinition>;
|
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
|
||||||
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -432,20 +418,14 @@ type StepNodePayload = StepRecord & {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 JSONata 求值上下文
|
### 4.4 Moderator 求值
|
||||||
|
|
||||||
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
|
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
/** JSONata 上下文中的 step — output 被展开 */
|
// graph[lastRole][lastOutput.status] → Target { role, prompt }
|
||||||
type StepContext = Omit<StepRecord, "output"> & {
|
// $START 角色使用 "_" 作为初始 status
|
||||||
output: unknown; // 展开后的 CAS 节点内容,非 hash
|
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
|
||||||
};
|
|
||||||
|
|
||||||
type ModeratorContext = {
|
|
||||||
start: StartNodePayload;
|
|
||||||
steps: StepContext[]; // 从旧到新
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.5 CLI 输出
|
### 4.5 CLI 输出
|
||||||
@@ -534,6 +514,5 @@ StepNodePayload ──extends──→ StepRecord ←──maps to──→ Step
|
|||||||
│
|
│
|
||||||
└── start.workflow → WorkflowPayload
|
└── start.workflow → WorkflowPayload
|
||||||
├── roles: Record<name, RoleDefinition>
|
├── roles: Record<name, RoleDefinition>
|
||||||
├── conditions: Record<name, JSONata>
|
└── graph: Record<role, Record<status, Target>>
|
||||||
└── graph: Record<role, Transition[]>
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ roles:
|
|||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
status:
|
||||||
|
enum: ["_"]
|
||||||
thesis:
|
thesis:
|
||||||
type: string
|
type: string
|
||||||
keyPoints:
|
keyPoints:
|
||||||
@@ -30,14 +32,9 @@ roles:
|
|||||||
type: string
|
type: string
|
||||||
caveats:
|
caveats:
|
||||||
type: string
|
type: string
|
||||||
required: [thesis, keyPoints]
|
required: [status, thesis, keyPoints]
|
||||||
conditions: {}
|
|
||||||
graph:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
- role: "analyst"
|
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||||
condition: null
|
|
||||||
prompt: "Analyze the topic in the task and produce a structured summary with key points."
|
|
||||||
analyst:
|
analyst:
|
||||||
- role: "$END"
|
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
||||||
condition: null
|
|
||||||
prompt: "Analysis complete. Finish the workflow."
|
|
||||||
|
|||||||
+15
-30
@@ -16,15 +16,16 @@ roles:
|
|||||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||||
output: |
|
output: |
|
||||||
Provide your argument in the frontmatter.
|
Provide your argument in the frontmatter.
|
||||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||||
|
Otherwise set status to "continue".
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
status:
|
||||||
|
enum: ["continue", "conceded"]
|
||||||
argument:
|
argument:
|
||||||
type: string
|
type: string
|
||||||
conceded:
|
required: [status, argument]
|
||||||
type: boolean
|
|
||||||
required: [argument, conceded]
|
|
||||||
for:
|
for:
|
||||||
description: "Argues for the proposition"
|
description: "Argues for the proposition"
|
||||||
goal: |
|
goal: |
|
||||||
@@ -40,38 +41,22 @@ roles:
|
|||||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||||
output: |
|
output: |
|
||||||
Provide your argument in the frontmatter.
|
Provide your argument in the frontmatter.
|
||||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||||
|
Otherwise set status to "continue".
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
status:
|
||||||
|
enum: ["continue", "conceded"]
|
||||||
argument:
|
argument:
|
||||||
type: string
|
type: string
|
||||||
conceded:
|
required: [status, argument]
|
||||||
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:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
- role: "against"
|
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||||
condition: null
|
|
||||||
prompt: "Present your opening argument against the proposition."
|
|
||||||
against:
|
against:
|
||||||
- role: "$END"
|
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
||||||
condition: "againstConceded"
|
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||||
prompt: "The against side conceded. Debate over."
|
|
||||||
- role: "for"
|
|
||||||
condition: null
|
|
||||||
prompt: "Counter the opposing argument. Address their points directly."
|
|
||||||
for:
|
for:
|
||||||
- role: "$END"
|
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
||||||
condition: "forConceded"
|
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||||
prompt: "The for side conceded. Debate over."
|
|
||||||
- role: "against"
|
|
||||||
condition: null
|
|
||||||
prompt: "Counter the opposing argument. Address their points directly."
|
|
||||||
|
|||||||
+20
-26
@@ -27,11 +27,13 @@ roles:
|
|||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
status:
|
||||||
|
enum: ["_"]
|
||||||
repoPath:
|
repoPath:
|
||||||
type: string
|
type: string
|
||||||
plan:
|
plan:
|
||||||
type: string
|
type: string
|
||||||
required: [repoPath, plan]
|
required: [status, repoPath, plan]
|
||||||
developer:
|
developer:
|
||||||
description: "Implements code changes"
|
description: "Implements code changes"
|
||||||
goal: "You are a developer agent. You implement code changes according to plans."
|
goal: "You are a developer agent. You implement code changes according to plans."
|
||||||
@@ -44,55 +46,47 @@ roles:
|
|||||||
2. cd to the repoPath before making any changes.
|
2. cd to the repoPath before making any changes.
|
||||||
3. Create a feature branch from the default branch.
|
3. Create a feature branch from the default branch.
|
||||||
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
||||||
5. Commit your changes with a descriptive message referencing the issue.
|
5. Run the project's lint/check command (e.g. `bun run check`, `npm run lint`) and fix ALL errors before proceeding. Build and lint must pass cleanly.
|
||||||
|
6. Commit your changes with a descriptive message referencing the issue.
|
||||||
output: "List all files changed and provide a summary of the implementation."
|
output: "List all files changed and provide a summary of the implementation."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
status:
|
||||||
|
enum: ["_"]
|
||||||
filesChanged:
|
filesChanged:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
summary:
|
summary:
|
||||||
type: string
|
type: string
|
||||||
required: [filesChanged, summary]
|
required: [status, filesChanged, summary]
|
||||||
reviewer:
|
reviewer:
|
||||||
description: "Reviews code changes"
|
description: "Reviews code changes"
|
||||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
||||||
capabilities:
|
capabilities:
|
||||||
- code-review
|
- code-review
|
||||||
- static-analysis
|
- static-analysis
|
||||||
procedure: "Review the implementation against the plan. Check for bugs, edge cases, and style."
|
procedure: |
|
||||||
|
1. Run hard checks first — build (`bun run build` or equivalent) and lint (`bunx biome check .` or equivalent) MUST pass with zero errors. If they fail, reject immediately.
|
||||||
|
2. Then review code quality: correctness, edge cases, naming, project conventions (CLAUDE.md), and test coverage.
|
||||||
|
3. Only reject for hard check failures or genuine correctness/security issues. Style suggestions alone should not block approval.
|
||||||
output: "Approve or reject with detailed comments explaining your decision."
|
output: "Approve or reject with detailed comments explaining your decision."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
approved:
|
status:
|
||||||
type: boolean
|
enum: ["approved", "rejected"]
|
||||||
comments:
|
comments:
|
||||||
type: string
|
type: string
|
||||||
required: [approved, comments]
|
required: [status, comments]
|
||||||
conditions:
|
|
||||||
notApproved:
|
|
||||||
description: "Reviewer rejected the implementation"
|
|
||||||
expression: "$last('reviewer').approved = false"
|
|
||||||
graph:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
- role: "planner"
|
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
|
||||||
condition: null
|
|
||||||
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
|
|
||||||
planner:
|
planner:
|
||||||
- role: "developer"
|
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
|
||||||
condition: null
|
|
||||||
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
|
|
||||||
developer:
|
developer:
|
||||||
- role: "reviewer"
|
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
|
||||||
condition: null
|
|
||||||
prompt: "Review the developer's implementation against the plan for correctness and quality."
|
|
||||||
reviewer:
|
reviewer:
|
||||||
- role: "developer"
|
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
|
||||||
condition: "notApproved"
|
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
|
||||||
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
prompt: "The review passed. Complete the workflow."
|
|
||||||
|
|||||||
@@ -531,13 +531,25 @@ export async function executeThread(
|
|||||||
timestamp: nowMs,
|
timestamp: nowMs,
|
||||||
parentState: options.parentStateHash,
|
parentState: options.parentStateHash,
|
||||||
},
|
},
|
||||||
steps: input.steps.map((out, i) => ({
|
steps: await Promise.all(
|
||||||
role: out.role,
|
input.steps.map(async (out, i) => {
|
||||||
contentHash: out.contentHash,
|
// Resolve content for the last step (most relevant for the next agent).
|
||||||
meta: out.meta,
|
// Earlier steps only carry meta summaries to avoid bloating the prompt.
|
||||||
refs: out.refs,
|
const isLast = i === input.steps.length - 1;
|
||||||
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
|
let content: string | null = null;
|
||||||
})),
|
if (isLast) {
|
||||||
|
content = await getContentMerklePayload(io.cas, out.contentHash);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: out.role,
|
||||||
|
contentHash: out.contentHash,
|
||||||
|
content,
|
||||||
|
meta: out.meta,
|
||||||
|
refs: out.refs,
|
||||||
|
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtime: WorkflowRuntime = {
|
const runtime: WorkflowRuntime = {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type RoleStep<M extends RoleMeta> = {
|
|||||||
role: K;
|
role: K;
|
||||||
meta: M[K];
|
meta: M[K];
|
||||||
contentHash: string;
|
contentHash: string;
|
||||||
|
content: string | null;
|
||||||
refs: string[];
|
refs: string[];
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
|
|||||||
cas: CasStore,
|
cas: CasStore,
|
||||||
): Promise<RoleStep<M>[]> {
|
): Promise<RoleStep<M>[]> {
|
||||||
const steps: RoleStep<M>[] = [];
|
const steps: RoleStep<M>[] = [];
|
||||||
for (const st of chronologicalStates) {
|
for (let idx = 0; idx < chronologicalStates.length; idx++) {
|
||||||
|
const st = chronologicalStates[idx];
|
||||||
if (st.payload.role === END) {
|
if (st.payload.role === END) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -79,10 +80,13 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
|
|||||||
if (contentParsed === null || contentParsed.kind !== "content") {
|
if (contentParsed === null || contentParsed.kind !== "content") {
|
||||||
throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`);
|
throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`);
|
||||||
}
|
}
|
||||||
|
// Resolve full text content for the last step only
|
||||||
|
const isLast = idx === chronologicalStates.length - 1;
|
||||||
steps.push({
|
steps.push({
|
||||||
role: st.payload.role,
|
role: st.payload.role,
|
||||||
meta: st.payload.meta,
|
meta: st.payload.meta,
|
||||||
contentHash: st.payload.content,
|
contentHash: st.payload.content,
|
||||||
|
content: isLast ? contentParsed.node.payload : null,
|
||||||
refs: [...contentParsed.node.refs],
|
refs: [...contentParsed.node.refs],
|
||||||
timestamp: st.payload.timestamp,
|
timestamp: st.payload.timestamp,
|
||||||
} as RoleStep<M>);
|
} as RoleStep<M>);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ async function advanceOneRound<M extends RoleMeta>(
|
|||||||
const step = {
|
const step = {
|
||||||
role: next,
|
role: next,
|
||||||
contentHash,
|
contentHash,
|
||||||
|
content: contentPayload,
|
||||||
meta,
|
meta,
|
||||||
refs,
|
refs,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).not.toContain("## Tools");
|
expect(text).not.toContain("## Tools");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single step shows hash and meta, and includes tools", async () => {
|
test("single step shows meta and content, and includes tools", async () => {
|
||||||
const onlyHash = "01HASHSINGLESTEP0000000001";
|
const onlyHash = "01HASHSINGLESTEP0000000001";
|
||||||
const ctx: AgentContext = {
|
const ctx: AgentContext = {
|
||||||
start: startTask("user task"),
|
start: startTask("user task"),
|
||||||
@@ -42,6 +42,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
contentHash: onlyHash,
|
contentHash: onlyHash,
|
||||||
|
content: "Here is my implementation of the feature.",
|
||||||
meta: { files: ["a.ts"] },
|
meta: { files: ["a.ts"] },
|
||||||
refs: [onlyHash],
|
refs: [onlyHash],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
@@ -52,13 +53,39 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("## Task");
|
expect(text).toContain("## Task");
|
||||||
expect(text).toContain("user task");
|
expect(text).toContain("user task");
|
||||||
expect(text).toContain("## Step: coder");
|
expect(text).toContain("## Step: coder");
|
||||||
expect(text).toContain(`ContentHash: ${onlyHash}`);
|
|
||||||
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("Here is my implementation of the feature.");
|
||||||
|
expect(text).toContain("</output>");
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("two or more steps: previous steps are meta-only; latest step includes hash", async () => {
|
test("single step with null content omits output tag", async () => {
|
||||||
|
const onlyHash = "01HASHSINGLESTEP0000000001";
|
||||||
|
const ctx: AgentContext = {
|
||||||
|
start: startTask("user task"),
|
||||||
|
depth: 0,
|
||||||
|
bundleHash: "TESTHASH00001",
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "coder",
|
||||||
|
contentHash: onlyHash,
|
||||||
|
content: null,
|
||||||
|
meta: { files: ["a.ts"] },
|
||||||
|
refs: [onlyHash],
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const text = await buildAgentPrompt(ctx);
|
||||||
|
expect(text).not.toContain("<output>");
|
||||||
|
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two or more steps: previous steps are meta-only; latest step includes content", async () => {
|
||||||
const plannerHash = "01HASHPLANNER0000000000001";
|
const plannerHash = "01HASHPLANNER0000000000001";
|
||||||
const coderHash = "01HASHCODER0000000000000001";
|
const coderHash = "01HASHCODER0000000000000001";
|
||||||
const ctx: AgentContext = {
|
const ctx: AgentContext = {
|
||||||
@@ -71,6 +98,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "planner",
|
role: "planner",
|
||||||
contentHash: plannerHash,
|
contentHash: plannerHash,
|
||||||
|
content: null,
|
||||||
meta: { plan: "short" },
|
meta: { plan: "short" },
|
||||||
refs: [plannerHash],
|
refs: [plannerHash],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
@@ -78,6 +106,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
contentHash: coderHash,
|
contentHash: coderHash,
|
||||||
|
content: "I reviewed the code and found 4 lint issues:\n1. Missing semicolon on line 42\n2. Unused import on line 3",
|
||||||
meta: { done: true },
|
meta: { done: true },
|
||||||
refs: [coderHash],
|
refs: [coderHash],
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
@@ -90,10 +119,11 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("### Step 1: planner");
|
expect(text).toContain("### Step 1: planner");
|
||||||
expect(text).toContain('Summary: {"plan":"short"}');
|
expect(text).toContain('Summary: {"plan":"short"}');
|
||||||
expect(text).toContain("## Latest Step: coder");
|
expect(text).toContain("## Latest Step: coder");
|
||||||
expect(text).toContain(`ContentHash: ${coderHash}`);
|
|
||||||
expect(text).toContain('Meta: {"done":true}');
|
expect(text).toContain('Meta: {"done":true}');
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("I reviewed the code and found 4 lint issues:");
|
||||||
|
expect(text).toContain("</output>");
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parentState null omits Parent Context section", async () => {
|
test("parentState null omits Parent Context section", async () => {
|
||||||
@@ -125,7 +155,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`);
|
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("middle steps show meta summary only and latest shows hash", async () => {
|
test("middle steps show meta summary only and latest shows content", async () => {
|
||||||
const ha = "01HASHA00000000000000000001";
|
const ha = "01HASHA00000000000000000001";
|
||||||
const hb = "01HASHB00000000000000000001";
|
const hb = "01HASHB00000000000000000001";
|
||||||
const hc = "01HASHC00000000000000000001";
|
const hc = "01HASHC00000000000000000001";
|
||||||
@@ -139,6 +169,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "a",
|
role: "a",
|
||||||
contentHash: ha,
|
contentHash: ha,
|
||||||
|
content: null,
|
||||||
meta: { n: 1 },
|
meta: { n: 1 },
|
||||||
refs: [ha],
|
refs: [ha],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
@@ -146,6 +177,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "b",
|
role: "b",
|
||||||
contentHash: hb,
|
contentHash: hb,
|
||||||
|
content: null,
|
||||||
meta: { n: 2 },
|
meta: { n: 2 },
|
||||||
refs: [hb],
|
refs: [hb],
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
@@ -153,6 +185,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "c",
|
role: "c",
|
||||||
contentHash: hc,
|
contentHash: hc,
|
||||||
|
content: "Final output from role c",
|
||||||
meta: { n: 3 },
|
meta: { n: 3 },
|
||||||
refs: [hc],
|
refs: [hc],
|
||||||
timestamp: 4,
|
timestamp: 4,
|
||||||
@@ -162,7 +195,35 @@ describe("buildAgentPrompt", () => {
|
|||||||
const text = await buildAgentPrompt(ctx);
|
const text = await buildAgentPrompt(ctx);
|
||||||
expect(text).toContain('Summary: {"n":1}');
|
expect(text).toContain('Summary: {"n":1}');
|
||||||
expect(text).toContain('Summary: {"n":2}');
|
expect(text).toContain('Summary: {"n":2}');
|
||||||
expect(text).toContain(`ContentHash: ${hc}`);
|
|
||||||
expect(text).toContain("## Latest Step: c");
|
expect(text).toContain("## Latest Step: c");
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("Final output from role c");
|
||||||
|
expect(text).toContain("</output>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content is truncated when exceeding quota", async () => {
|
||||||
|
const longContent = "x".repeat(20_000);
|
||||||
|
const hash = "01HASHLONG000000000000000001";
|
||||||
|
const ctx: AgentContext = {
|
||||||
|
start: startTask("task"),
|
||||||
|
depth: 0,
|
||||||
|
bundleHash: "TESTHASH00001",
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "r", systemPrompt: "S" },
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "r",
|
||||||
|
contentHash: hash,
|
||||||
|
content: longContent,
|
||||||
|
meta: {},
|
||||||
|
refs: [],
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const text = await buildAgentPrompt(ctx);
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("... (truncated)");
|
||||||
|
expect(text.length).toBeLessThan(20_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"uwf": "bun packages/cli-workflow/src/cli.ts",
|
||||||
"build": "bunx tsc --build",
|
"build": "bunx tsc --build",
|
||||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ bun link packages/cli-workflow
|
|||||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
|
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
|
||||||
| `uwf thread exec <thread-id> [--agent <cmd>] [-c <count>] [--background]` | Execute one or more moderator→agent→extract cycles |
|
| `uwf thread exec <thread-id> [--agent <cmd>] [-c <count>] [--background]` | Execute one or more moderator→agent→extract cycles |
|
||||||
| `uwf thread show <thread-id>` | Show thread head pointer |
|
| `uwf thread show <thread-id>` | Show thread head pointer |
|
||||||
| `uwf thread list [--status <idle\|running\|completed>]` | List threads, optionally filtered by status |
|
| `uwf thread list [--status <status>] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads filtered by status (idle, running, completed, active, or comma-separated), time range (ISO or relative like '7d'), with pagination |
|
||||||
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
|
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
|
||||||
|
|
||||||
|
`thread read`, `step list`, and `step show` work on both active and completed threads.
|
||||||
| `uwf thread stop <thread-id>` | Stop background execution (keep thread active) |
|
| `uwf thread stop <thread-id>` | Stop background execution (keep thread active) |
|
||||||
| `uwf thread cancel <thread-id>` | Cancel thread (stop + archive to history) |
|
| `uwf thread cancel <thread-id>` | Cancel thread (stop + archive to history) |
|
||||||
|
|
||||||
@@ -62,6 +64,9 @@ uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
|||||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
|
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
|
||||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
|
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
|
||||||
uwf thread list --status running
|
uwf thread list --status running
|
||||||
|
uwf thread list --status active
|
||||||
|
uwf thread list --status idle,completed
|
||||||
|
uwf thread list --after 7d --take 10
|
||||||
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
|
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
|
||||||
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||||
```
|
```
|
||||||
@@ -72,6 +77,7 @@ uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `uwf step list <thread-id>` | List all steps in a thread chronologically |
|
| `uwf step list <thread-id>` | List all steps in a thread chronologically |
|
||||||
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
|
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
|
||||||
|
| `uwf step read <step-hash> [--quota <chars>]` | Read a step's turns as human-readable markdown |
|
||||||
| `uwf step fork <step-hash>` | Fork a thread from a specific step |
|
| `uwf step fork <step-hash>` | Fork a thread from a specific step |
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -79,6 +85,7 @@ Examples:
|
|||||||
```bash
|
```bash
|
||||||
uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||||
uwf step show 32GCDE899RRQ3
|
uwf step show 32GCDE899RRQ3
|
||||||
|
uwf step read 32GCDE899RRQ3 --quota 2000
|
||||||
uwf step fork 32GCDE899RRQ3
|
uwf step fork 32GCDE899RRQ3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdCasPutText } from "../commands/cas.js";
|
||||||
|
|
||||||
|
let storageRoot: string;
|
||||||
|
let uwfPath: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storageRoot = join(
|
||||||
|
tmpdir(),
|
||||||
|
`uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
|
||||||
|
// Find the uwf CLI path
|
||||||
|
uwfPath = join(__dirname, "../../src/cli.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
type ExecResult = {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function execUwf(args: string[]): ExecResult {
|
||||||
|
try {
|
||||||
|
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
|
||||||
|
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
return { stdout, stderr: "", exitCode: 0 };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === "object" &&
|
||||||
|
"stdout" in error &&
|
||||||
|
"stderr" in error &&
|
||||||
|
"status" in error
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
stdout: (error.stdout as Buffer | string).toString(),
|
||||||
|
stderr: (error.stderr as Buffer | string).toString(),
|
||||||
|
exitCode: error.status as number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("uwf cas has CLI exit codes", () => {
|
||||||
|
test("exits 0 when hash exists", async () => {
|
||||||
|
// Setup: Create a temp storage root, put a text node, capture hash
|
||||||
|
const putResult = await cmdCasPutText(storageRoot, "test content");
|
||||||
|
const hash = putResult.hash;
|
||||||
|
|
||||||
|
// Execute: uwf cas has <hash>
|
||||||
|
const result = execUwf(["cas", "has", hash]);
|
||||||
|
|
||||||
|
// Assert: stdout contains {"exists":true}, exit code === 0
|
||||||
|
expect(result.stdout).toContain('"exists":true');
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exits 1 when hash does not exist", () => {
|
||||||
|
// Setup: Create a temp storage root (empty CAS store)
|
||||||
|
// Execute: uwf cas has NOSUCHHASH123
|
||||||
|
const result = execUwf(["cas", "has", "NOSUCHHASH123"]);
|
||||||
|
|
||||||
|
// Assert: stdout contains {"exists":false}, exit code === 1
|
||||||
|
expect(result.stdout).toContain('"exists":false');
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSON output format unchanged for exists=true", async () => {
|
||||||
|
// Setup: Create store, put node
|
||||||
|
const putResult = await cmdCasPutText(storageRoot, "test");
|
||||||
|
const hash = putResult.hash;
|
||||||
|
|
||||||
|
// Execute: uwf cas has <hash>
|
||||||
|
const result = execUwf(["cas", "has", hash]);
|
||||||
|
|
||||||
|
// Assert: stdout JSON parses correctly to {exists: true}
|
||||||
|
const parsed = JSON.parse(result.stdout.trim());
|
||||||
|
expect(parsed).toEqual({ exists: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSON output format unchanged for exists=false", () => {
|
||||||
|
// Setup: Create empty store
|
||||||
|
// Execute: uwf cas has INVALID
|
||||||
|
const result = execUwf(["cas", "has", "INVALID"]);
|
||||||
|
|
||||||
|
// Assert: stdout JSON parses correctly to {exists: false}
|
||||||
|
const parsed = JSON.parse(result.stdout.trim());
|
||||||
|
expect(parsed).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("YAML output format preserves exit code behavior for exists=true", async () => {
|
||||||
|
// Setup: Create store with node
|
||||||
|
const putResult = await cmdCasPutText(storageRoot, "test");
|
||||||
|
const hash = putResult.hash;
|
||||||
|
|
||||||
|
// Execute: uwf --format yaml cas has <hash>
|
||||||
|
const result = execUwf(["--format", "yaml", "cas", "has", hash]);
|
||||||
|
|
||||||
|
// Assert: exit code === 0, output is YAML format
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout).toContain("exists:");
|
||||||
|
expect(result.stdout).toContain("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("YAML output format preserves exit code behavior for exists=false", () => {
|
||||||
|
// Setup: Create empty store
|
||||||
|
// Execute: uwf --format yaml cas has INVALID
|
||||||
|
const result = execUwf(["--format", "yaml", "cas", "has", "INVALID"]);
|
||||||
|
|
||||||
|
// Assert: exit code === 1, output is YAML format
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toContain("exists:");
|
||||||
|
expect(result.stdout).toContain("false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("regression: other cas commands unaffected", () => {
|
||||||
|
test("uwf cas get still exits 1 on not-found with error message", () => {
|
||||||
|
// Execute: uwf cas get NOSUCHHASH
|
||||||
|
const result = execUwf(["cas", "get", "NOSUCHHASH"]);
|
||||||
|
|
||||||
|
// Assert: exit code === 1, stderr contains "Node not found"
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stderr).toContain("Node not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uwf cas put-text behavior unchanged", () => {
|
||||||
|
// Execute: uwf cas put-text "hello"
|
||||||
|
const result = execUwf(["cas", "put-text", "hello"]);
|
||||||
|
|
||||||
|
// Assert: exit code === 0, returns hash
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
const parsed = JSON.parse(result.stdout.trim());
|
||||||
|
expect(parsed).toHaveProperty("hash");
|
||||||
|
expect(typeof parsed.hash).toBe("string");
|
||||||
|
expect(parsed.hash.length).toBe(13); // Crockford Base32 XXH64 hash length
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdCasHas, cmdCasPutText } from "../commands/cas.js";
|
||||||
|
|
||||||
|
let storageRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storageRoot = join(tmpdir(), `uwf-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdCasHas", () => {
|
||||||
|
test("returns {exists: true} for existing hash", async () => {
|
||||||
|
// Setup: Create a test store, put a node, get its hash
|
||||||
|
const putResult = await cmdCasPutText(storageRoot, "test content");
|
||||||
|
const hash = putResult.hash;
|
||||||
|
|
||||||
|
// Execute: Call cmdCasHas with the valid hash
|
||||||
|
const result = await cmdCasHas(storageRoot, hash);
|
||||||
|
|
||||||
|
// Assert: Result equals {exists: true}
|
||||||
|
expect(result).toEqual({ exists: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns {exists: false} for non-existent hash", async () => {
|
||||||
|
// Setup: Create an empty test store
|
||||||
|
// (storageRoot already created in beforeEach)
|
||||||
|
|
||||||
|
// Execute: Call cmdCasHas with an invalid hash
|
||||||
|
const result = await cmdCasHas(storageRoot, "INVALIDHASH12");
|
||||||
|
|
||||||
|
// Assert: Result equals {exists: false}
|
||||||
|
expect(result).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not throw for non-existent hash", async () => {
|
||||||
|
// Setup: Create an empty test store
|
||||||
|
// Execute & Assert: Does not throw, returns {exists: false}
|
||||||
|
await expect(cmdCasHas(storageRoot, "NOSUCHHASH123")).resolves.toEqual({
|
||||||
|
exists: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles malformed hash gracefully", async () => {
|
||||||
|
// Setup: Create a test store
|
||||||
|
// Execute: Call cmdCasHas with a too-short hash
|
||||||
|
const result = await cmdCasHas(storageRoot, "xyz");
|
||||||
|
|
||||||
|
// Assert: Returns {exists: false} (store.has() returns false)
|
||||||
|
expect(result).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty hash string", async () => {
|
||||||
|
// Execute: Call cmdCasHas with an empty string
|
||||||
|
const result = await cmdCasHas(storageRoot, "");
|
||||||
|
|
||||||
|
// Assert: Returns {exists: false}
|
||||||
|
expect(result).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles hash with special characters", async () => {
|
||||||
|
// Execute: Call cmdCasHas with special characters
|
||||||
|
const result = await cmdCasHas(storageRoot, "HASH!@#");
|
||||||
|
|
||||||
|
// Assert: Returns {exists: false}
|
||||||
|
expect(result).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { resolveHeadHash } from "../commands/shared.js";
|
||||||
|
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-resolve-head-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHeadHash", () => {
|
||||||
|
test("returns head hash from threads.yaml for active thread", async () => {
|
||||||
|
const threadId = "01JTEST0000000000000000001" as ThreadId;
|
||||||
|
const headHash = "active_hash_123" as CasRef;
|
||||||
|
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result).toBe(headHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to history.jsonl when thread not in threads.yaml", async () => {
|
||||||
|
const threadId = "01JTEST0000000000000000002" as ThreadId;
|
||||||
|
const headHash = "completed_hash_456" as CasRef;
|
||||||
|
const workflowHash = "workflow_hash_789" as CasRef;
|
||||||
|
|
||||||
|
// No entry in threads.yaml, only in history.jsonl
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: headHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result).toBe(headHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Testing the error case requires CLI-level testing because resolveHeadHash
|
||||||
|
// calls fail() which does process.exit(1), terminating the test runner.
|
||||||
|
// The error behavior is tested in integration tests below via CLI invocation.
|
||||||
|
|
||||||
|
test("prioritizes active thread over history when thread exists in both", async () => {
|
||||||
|
const threadId = "01JTEST0000000000000000004" as ThreadId;
|
||||||
|
const activeHash = "active_hash_v2" as CasRef;
|
||||||
|
const historicalHash = "historical_hash_v1" as CasRef;
|
||||||
|
const workflowHash = "workflow_hash_xyz" as CasRef;
|
||||||
|
|
||||||
|
// Thread exists in both locations (should not happen normally, but test the precedence)
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: activeHash });
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: historicalHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
|
|
||||||
|
// Should return the active head, not the historical one
|
||||||
|
expect(result).toBe(activeHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds thread from multiple history entries", async () => {
|
||||||
|
const threadId1 = "01JTEST0000000000000000005" as ThreadId;
|
||||||
|
const threadId2 = "01JTEST0000000000000000006" as ThreadId;
|
||||||
|
const threadId3 = "01JTEST0000000000000000007" as ThreadId;
|
||||||
|
const hash1 = "hash_thread1" as CasRef;
|
||||||
|
const hash2 = "hash_thread2" as CasRef;
|
||||||
|
const hash3 = "hash_thread3" as CasRef;
|
||||||
|
const workflowHash = "workflow_hash_abc" as CasRef;
|
||||||
|
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId1,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: hash1,
|
||||||
|
completedAt: Date.now() - 2000,
|
||||||
|
});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId2,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: hash2,
|
||||||
|
completedAt: Date.now() - 1000,
|
||||||
|
});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId3,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: hash3,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId2);
|
||||||
|
|
||||||
|
expect(result).toBe(hash2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test: Issue #474 - tea pr create fails in git worktree directories
|
||||||
|
*
|
||||||
|
* This test verifies that the solve-issue workflow's committer role
|
||||||
|
* includes the --repo flag when running tea pr create, which fixes
|
||||||
|
* the "path segment [0] is empty" error in worktree directories.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||||
|
// Navigate up from packages/cli-workflow to repo root
|
||||||
|
const workflowPath = join(process.cwd(), "..", "..", ".workflows", "solve-issue.yaml");
|
||||||
|
|
||||||
|
test("committer procedure should include --repo flag in tea pr create command", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
expect(workflow.roles.committer).toBeDefined();
|
||||||
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the procedure includes tea pr create with --repo flag
|
||||||
|
expect(committerProcedure).toContain("tea pr create");
|
||||||
|
expect(committerProcedure).toContain("--repo");
|
||||||
|
|
||||||
|
// Verify the --repo flag appears before or together with tea pr create
|
||||||
|
// This ensures the command is: tea pr create --repo <owner/repo> ...
|
||||||
|
const teaPrCreateMatch = committerProcedure?.match(/tea pr create[^\n]*/);
|
||||||
|
expect(teaPrCreateMatch).not.toBeNull();
|
||||||
|
|
||||||
|
if (teaPrCreateMatch) {
|
||||||
|
const teaCommandLine = teaPrCreateMatch[0];
|
||||||
|
expect(teaCommandLine).toContain("--repo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer procedure should mention repo extraction from git remote", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the procedure mentions extracting repo info from git remote
|
||||||
|
// This ensures fallback logic is documented
|
||||||
|
expect(committerProcedure).toMatch(/git remote/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer procedure should include error handling for tea failures", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the procedure includes error handling guidance
|
||||||
|
// This ensures we capture failures and provide actionable output
|
||||||
|
expect(committerProcedure).toMatch(/error|fail/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workflow should be parseable as valid WorkflowPayload", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
// Basic structure validation
|
||||||
|
expect(workflow.name).toBe("solve-issue");
|
||||||
|
expect(workflow.roles).toBeDefined();
|
||||||
|
expect(workflow.graph).toBeDefined();
|
||||||
|
|
||||||
|
// Verify committer role exists with required fields
|
||||||
|
expect(workflow.roles.committer).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.description).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.goal).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.procedure).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.output).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer frontmatter schema should require status 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?.type).toBe("object");
|
||||||
|
expect(frontmatter?.properties?.status).toBeDefined();
|
||||||
|
expect(frontmatter?.properties?.status?.enum).toContain("committed");
|
||||||
|
expect(frontmatter?.required).toContain("status");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
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 } from "@uncaged/workflow-protocol";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdStepRead } from "../commands/step.js";
|
||||||
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
|
|
||||||
|
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateContent(size: number, prefix = "Content"): string {
|
||||||
|
const base = `${prefix} `;
|
||||||
|
const repeat = Math.ceil(size / base.length);
|
||||||
|
return base.repeat(repeat).slice(0, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── step read tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("step read", () => {
|
||||||
|
test("test 1: basic single-step read with 3 turns", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 3 turns
|
||||||
|
const turnHashes: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const content = `Turn ${i} content with some text to make it readable.`;
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: i - 1,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
turnHashes.push(turnHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 3,
|
||||||
|
turns: turnHashes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step with large quota
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
|
||||||
|
|
||||||
|
// Assert structure
|
||||||
|
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||||
|
expect(markdown).toContain("**Role:** worker");
|
||||||
|
expect(markdown).toContain("**Agent:** uwf-test");
|
||||||
|
expect(markdown).toContain("## Turn 1");
|
||||||
|
expect(markdown).toContain("## Turn 2");
|
||||||
|
expect(markdown).toContain("## Turn 3");
|
||||||
|
expect(markdown).toContain("Turn 1 content with some text to make it readable.");
|
||||||
|
expect(markdown).toContain("Turn 2 content with some text to make it readable.");
|
||||||
|
expect(markdown).toContain("Turn 3 content with some text to make it readable.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 2: quota enforcement - multiple turns", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 4 turns of ~300 chars each
|
||||||
|
const turnHashes: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
const content = generateContent(300, `Turn${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: i - 1,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
turnHashes.push(turnHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 4,
|
||||||
|
turns: turnHashes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step with limited quota (700 chars)
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 700);
|
||||||
|
|
||||||
|
// Assert only most recent turns fit
|
||||||
|
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||||
|
// Should have skip hint
|
||||||
|
expect(markdown).toContain("Earlier turns omitted");
|
||||||
|
// Should include at least Turn 4 (most recent)
|
||||||
|
expect(markdown).toContain("Turn4");
|
||||||
|
// Total length should respect quota (with tolerance for structural overhead)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(900); // 700 quota + 200 buffer tolerance
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 3: minimal quota edge case - always show at least one turn", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 1 turn of 500 chars
|
||||||
|
const content = generateContent(500, "LongTurn");
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step with minimal quota (1 char)
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 1);
|
||||||
|
|
||||||
|
// Assert at least one turn is always shown
|
||||||
|
expect(markdown).toContain("LongTurn");
|
||||||
|
expect(markdown.length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 4: step with no detail field", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step - should return metadata only (no error)
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||||
|
|
||||||
|
// Assert metadata is present
|
||||||
|
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||||
|
expect(markdown).toContain("**Role:** worker");
|
||||||
|
expect(markdown).toContain("**Agent:** uwf-test");
|
||||||
|
// Should not have turn sections
|
||||||
|
expect(markdown).not.toContain("## Turn");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 5: step with detail but no turns array", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create detail with different schema (no turns)
|
||||||
|
const SIMPLE_DETAIL_SCHEMA = {
|
||||||
|
title: "simple-detail",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["sessionId"],
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: "string" as const },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await bootstrap(store);
|
||||||
|
const simpleDetailType = await putSchema(store, SIMPLE_DETAIL_SCHEMA);
|
||||||
|
const detailHash = await store.put(simpleDetailType, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step - should return metadata only (no error)
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||||
|
|
||||||
|
// Assert metadata is present
|
||||||
|
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||||
|
expect(markdown).toContain("**Role:** worker");
|
||||||
|
// Should not have turn sections
|
||||||
|
expect(markdown).not.toContain("## Turn");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 6: turn content with special characters", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create turn with special markdown characters
|
||||||
|
const content = "This has `backticks`, **bold**, *italic*, and [links](http://example.com)";
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||||
|
|
||||||
|
// Assert content is rendered correctly without corruption
|
||||||
|
expect(markdown).toContain("`backticks`");
|
||||||
|
expect(markdown).toContain("**bold**");
|
||||||
|
expect(markdown).toContain("*italic*");
|
||||||
|
expect(markdown).toContain("[links](http://example.com)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import { extractUlidTimestamp, generateUlid } from "@uncaged/workflow-util";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { createMarker, deleteMarker } from "../background/index.js";
|
||||||
|
import { cmdThreadList } from "../commands/thread.js";
|
||||||
|
import { parseTimeInput } from "../commands/thread-time-parser.js";
|
||||||
|
import type { UwfStore } from "../store.js";
|
||||||
|
import { appendThreadHistory, createUwfStore, saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||||
|
const casDir = join(storageRoot, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
return createUwfStore(storageRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestWorkflow(uwf: UwfStore): Promise<CasRef> {
|
||||||
|
const workflowPayload = {
|
||||||
|
name: "test-workflow",
|
||||||
|
roles: {
|
||||||
|
role1: {
|
||||||
|
goal: "test goal",
|
||||||
|
outputSchema: { type: "object" as const, properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: { start: "role1" },
|
||||||
|
conditions: {},
|
||||||
|
};
|
||||||
|
return await uwf.store.put(uwf.schemas.workflow, workflowPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestThread(
|
||||||
|
uwf: UwfStore,
|
||||||
|
storageRoot: string,
|
||||||
|
workflowHash: CasRef,
|
||||||
|
timestamp: number,
|
||||||
|
): Promise<ThreadId> {
|
||||||
|
const threadId = generateUlid(timestamp) as ThreadId;
|
||||||
|
const startPayload = {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "test prompt",
|
||||||
|
};
|
||||||
|
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||||
|
index[threadId] = headHash;
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
return threadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markThreadRunning(storageRoot: string, threadId: ThreadId, workflow: CasRef) {
|
||||||
|
await createMarker(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow,
|
||||||
|
pid: process.pid, // Use current process PID so isPidAlive returns true
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeThread(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
workflowHash: CasRef,
|
||||||
|
headHash: CasRef,
|
||||||
|
) {
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||||
|
delete index[threadId];
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
await appendThreadHistory(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: headHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── test setup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "thread-list-filters-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── status filter tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadList status filter", () => {
|
||||||
|
test("should return idle and running threads when status=active", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle", "running"], null, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
|
||||||
|
|
||||||
|
// Clean up marker after test
|
||||||
|
await deleteMarker(tmpDir, thread2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support comma-separated status values", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle", "completed"], null, null, null, null);
|
||||||
|
|
||||||
|
// Clean up marker
|
||||||
|
await deleteMarker(tmpDir, thread2);
|
||||||
|
|
||||||
|
// thread2 is running (not idle), so should not be included
|
||||||
|
// Expected: thread1 (idle) and thread3 (completed)
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread3].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support single status filter (backward compat)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.thread).toBe(thread3);
|
||||||
|
expect(result[0]?.status).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return all threads when no status filter provided", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2, thread3].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── time range filtering tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadList time filters", () => {
|
||||||
|
test("should filter threads created after given timestamp", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
|
||||||
|
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
|
||||||
|
// Use a timestamp slightly before ts2 to include threadB
|
||||||
|
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([threadB, threadC].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter threads created before given timestamp", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
|
||||||
|
const threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
|
||||||
|
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, beforeMs, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([threadA, threadB].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support both after and before filters (time range)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
|
||||||
|
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||||
|
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, beforeMs, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.thread).toBe(threadB);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pagination tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadList pagination", () => {
|
||||||
|
test("should limit results with --take", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() - i * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, 5);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip first N threads with --skip", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
// Create threads in chronological order, but they'll be sorted newest first
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
|
||||||
|
// Small delay to ensure distinct timestamps
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, 3, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(7);
|
||||||
|
// The 3 newest threads should be skipped, so we should get the 7 oldest
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support skip + take for pagination", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, 5, 3);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
// Should skip first 5 (newest), then take 3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle take > available threads", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const _thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, 10);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array when skip >= thread count", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, 5, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── combined filters tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("combined filters", () => {
|
||||||
|
test("should combine status and time range filters", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
const ts4 = Date.UTC(2026, 4, 23, 0, 0, 0);
|
||||||
|
|
||||||
|
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
const thread4 = await createTestThread(uwf, tmpDir, workflowHash, ts4);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle"], afterMs, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.thread).toBe(thread4);
|
||||||
|
expect(result[0]?.status).toBe("idle");
|
||||||
|
|
||||||
|
// Clean up marker
|
||||||
|
await deleteMarker(tmpDir, thread2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should combine status filter and pagination", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 9; i >= 0; i--) {
|
||||||
|
const thread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 1000);
|
||||||
|
threads.push(thread);
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const headHash = index[thread];
|
||||||
|
if (headHash === undefined) throw new Error("head not found");
|
||||||
|
await completeThread(tmpDir, thread, workflowHash, headHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["completed"], null, null, 3, 5);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
for (const r of result) {
|
||||||
|
expect(r.status).toBe("completed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should combine time range and pagination", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const ts = Date.UTC(2026, 4, 1 + i, 0, 0, 0);
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 10, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, null, 2, 5);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
for (const r of result) {
|
||||||
|
const ts = extractUlidTimestamp(r.thread);
|
||||||
|
expect(ts).not.toBeNull();
|
||||||
|
if (ts !== null) {
|
||||||
|
expect(ts).toBeGreaterThan(afterMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupMixedStatusThreads(
|
||||||
|
uwf: UwfStore,
|
||||||
|
workflowHash: string,
|
||||||
|
count: number,
|
||||||
|
): Promise<ThreadId[]> {
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const ts = Date.UTC(2026, 4, 10 + i, 0, 0, 0);
|
||||||
|
const thread = await createTestThread(uwf, tmpDir, workflowHash, ts);
|
||||||
|
threads.push(thread);
|
||||||
|
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const headHash = index[thread];
|
||||||
|
if (headHash === undefined) throw new Error("head not found");
|
||||||
|
await completeThread(tmpDir, thread, workflowHash, headHash);
|
||||||
|
} else {
|
||||||
|
await markThreadRunning(tmpDir, thread, workflowHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return threads;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupRunningMarkers(threads: ThreadId[]): Promise<void> {
|
||||||
|
for (let i = 0; i < threads.length; i++) {
|
||||||
|
if (i % 2 !== 0) {
|
||||||
|
await deleteMarker(tmpDir, threads[i] as ThreadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should combine all filters (status + time + pagination)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
const threads = await setupMixedStatusThreads(uwf, workflowHash, 15);
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 14, 12, 0, 0);
|
||||||
|
const beforeMs = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle", "running"], afterMs, beforeMs, 1, 3);
|
||||||
|
|
||||||
|
expect(result.length).toBeLessThanOrEqual(3);
|
||||||
|
for (const r of result) {
|
||||||
|
expect(["idle", "running"]).toContain(r.status);
|
||||||
|
const ts = extractUlidTimestamp(r.thread);
|
||||||
|
if (ts !== null) {
|
||||||
|
expect(ts).toBeGreaterThan(afterMs);
|
||||||
|
expect(ts).toBeLessThan(beforeMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanupRunningMarkers(threads);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── edge cases tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
test("should handle empty thread list", async () => {
|
||||||
|
await makeUwfStore(tmpDir);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip threads with invalid ULID when time filtering", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
index["INVALID_ULID_FORMAT_HERE" as ThreadId] = "01J6HMVRNQKJV2";
|
||||||
|
await saveThreadsIndex(tmpDir, index);
|
||||||
|
|
||||||
|
const afterMs = Date.now() - 3000;
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── time parsing tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("relative time parsing", () => {
|
||||||
|
test("should parse '7d' as 7 days ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
|
||||||
|
const result = parseTimeInput("7d", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 17, 12, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse '24h' as 24 hours ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
|
||||||
|
const result = parseTimeInput("24h", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 23, 12, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse '30m' as 30 minutes ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 12, 30, 0);
|
||||||
|
const result = parseTimeInput("30m", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 24, 12, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse '1d' as 1 day ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 0, 0, 0);
|
||||||
|
const result = parseTimeInput("1d", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 23, 0, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ISO date parsing", () => {
|
||||||
|
test("should parse ISO date (YYYY-MM-DD)", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const result = parseTimeInput("2026-05-20", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse ISO datetime (YYYY-MM-DDTHH:MM:SS)", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const result = parseTimeInput("2026-05-20T14:30:00", nowMs);
|
||||||
|
const expected = Date.parse("2026-05-20T14:30:00");
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse ISO datetime with Z suffix", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const result = parseTimeInput("2026-05-20T14:30:00Z", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 20, 14, 30, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject invalid date formats", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
expect(() => parseTimeInput("not-a-date", nowMs)).toThrow();
|
||||||
|
expect(() => parseTimeInput("2026-13-01", nowMs)).toThrow();
|
||||||
|
expect(() => parseTimeInput("invalid", nowMs)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
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 { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdThreadRead } from "../commands/thread.js";
|
||||||
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
|
import { saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateContent(size: number, prefix = "Content"): string {
|
||||||
|
const base = `${prefix} `;
|
||||||
|
const repeat = Math.ceil(size / base.length);
|
||||||
|
return base.repeat(repeat).slice(0, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── thread read quota enforcement ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("thread read --quota flag", () => {
|
||||||
|
test("test 1: basic quota enforcement with 3 steps", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 3 steps with ~500 chars each
|
||||||
|
const steps: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const content = generateContent(500, `Step${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: steps[i - 2] ?? null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
steps.push(stepHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ0" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
|
||||||
|
|
||||||
|
// Set quota to 800 chars - should only fit most recent steps
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 800, null, false);
|
||||||
|
|
||||||
|
// Quota must be reasonably enforced (allow ~200 char tolerance for skip hint)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(1000);
|
||||||
|
|
||||||
|
// Should contain skip hint since not all steps fit
|
||||||
|
expect(markdown).toMatch(/earlier step/);
|
||||||
|
|
||||||
|
// Most recent step should be included
|
||||||
|
expect(markdown).toMatch(/Step3/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 2: quota check order - verifies bug is fixed", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 2 steps: first=300 chars, second=600 chars
|
||||||
|
const step1Content = generateContent(300, "First");
|
||||||
|
const step1TurnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: step1Content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const step1DetailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [step1TurnHash],
|
||||||
|
});
|
||||||
|
const step1Hash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: step1DetailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const step2Content = generateContent(600, "Second");
|
||||||
|
const step2TurnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: step2Content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const step2DetailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-2",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [step2TurnHash],
|
||||||
|
});
|
||||||
|
const step2Hash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: step2DetailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: step2Hash });
|
||||||
|
|
||||||
|
// Set quota to 500 chars
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 500, null, false);
|
||||||
|
|
||||||
|
// Bug fix verification: output must be limited (allow ~200 char tolerance)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(1100);
|
||||||
|
|
||||||
|
// Should contain "Second" (most recent step)
|
||||||
|
expect(markdown).toMatch(/Second/);
|
||||||
|
|
||||||
|
// Should skip first step
|
||||||
|
expect(markdown).toMatch(/earlier step/);
|
||||||
|
|
||||||
|
// Verify improvement: before fix would be ~1264, now should be much closer to 500
|
||||||
|
expect(markdown.length).toBeLessThan(1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 3: quota with --start section", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task with a moderately long prompt to test quota accounting",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 2 steps
|
||||||
|
const steps: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 2; i++) {
|
||||||
|
const content = generateContent(400, `Step${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: steps[i - 2] ?? null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
steps.push(stepHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ2" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: steps[1] as CasRef });
|
||||||
|
|
||||||
|
// Set tight quota with --start flag
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
|
||||||
|
|
||||||
|
// Quota must be reasonably enforced (allow ~210 char tolerance for structure)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(810);
|
||||||
|
|
||||||
|
// Should contain thread header
|
||||||
|
expect(markdown).toMatch(/# Thread/);
|
||||||
|
expect(markdown).toMatch(/test-wf/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 5a: quota edge case - minimal quota", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = generateContent(500, "Test");
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
// Minimal quota
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
|
||||||
|
|
||||||
|
// Should handle gracefully - always shows at least one step
|
||||||
|
expect(markdown.length).toBeGreaterThan(1);
|
||||||
|
expect(markdown).toMatch(/Test/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 5b: quota edge case - very large quota", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 3 steps
|
||||||
|
const steps: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const content = generateContent(300, `Step${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: steps[i - 2] ?? null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
steps.push(stepHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ5" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
|
||||||
|
|
||||||
|
// Very large quota
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 1000000, null, false);
|
||||||
|
|
||||||
|
// Should show all steps (no skipping)
|
||||||
|
expect(markdown).not.toMatch(/earlier step/);
|
||||||
|
expect(markdown).toMatch(/Step1/);
|
||||||
|
expect(markdown).toMatch(/Step2/);
|
||||||
|
expect(markdown).toMatch(/Step3/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 6: quota with --before parameter", 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: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 5 steps
|
||||||
|
const steps: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const content = generateContent(300, `Step${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: steps[i - 2] ?? null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
steps.push(stepHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ6" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: steps[4] as CasRef });
|
||||||
|
|
||||||
|
// Use --before to limit to steps 1-2, then set quota that allows only 1
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 500, steps[2] as CasRef, false);
|
||||||
|
|
||||||
|
// Should not contain Step3 or later
|
||||||
|
expect(markdown).not.toMatch(/Step3/);
|
||||||
|
expect(markdown).not.toMatch(/Step4/);
|
||||||
|
expect(markdown).not.toMatch(/Step5/);
|
||||||
|
|
||||||
|
// Quota should select most recent of candidates (Step2)
|
||||||
|
expect(markdown).toMatch(/Step2/);
|
||||||
|
|
||||||
|
// Quota enforcement (allow ~200 char tolerance)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(700);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { bootstrap, putSchema } from "@uncaged/json-cas";
|
|||||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { cmdStepShow } from "../commands/step.js";
|
import { cmdStepList, cmdStepShow } from "../commands/step.js";
|
||||||
import {
|
import {
|
||||||
cmdThreadRead,
|
cmdThreadRead,
|
||||||
extractLastAssistantContent,
|
extractLastAssistantContent,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "../commands/thread.js";
|
} from "../commands/thread.js";
|
||||||
import { registerUwfSchemas } from "../schemas.js";
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
import type { UwfStore } from "../store.js";
|
import type { UwfStore } from "../store.js";
|
||||||
import { saveThreadsIndex } from "../store.js";
|
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
// ── schemas used in tests ────────────────────────────────────────────────────
|
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -647,3 +647,383 @@ describe("cmdStepShow (process.exit tests - must be last)", () => {
|
|||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── cmdStepList / cmdStepShow: completed threads ──────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdStepList with completed threads", () => {
|
||||||
|
test("lists steps from active thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-active",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Start prompt",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "role1",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "role2",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step3Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step2Hash,
|
||||||
|
role: "role3",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000A1" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: step3Hash });
|
||||||
|
|
||||||
|
const result = await cmdStepList(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result.thread).toBe(threadId);
|
||||||
|
expect(result.steps).toHaveLength(4); // start + 3 steps
|
||||||
|
expect(result.steps[1].role).toBe("role1");
|
||||||
|
expect(result.steps[2].role).toBe("role2");
|
||||||
|
expect(result.steps[3].role).toBe("role3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists steps from completed thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-completed",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Start prompt",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "roleA",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "roleB",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000A2" as ThreadId;
|
||||||
|
// Thread is NOT in threads.yaml (simulating completed thread)
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
// But it IS in history.jsonl
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: step2Hash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cmdStepList(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result.thread).toBe(threadId);
|
||||||
|
expect(result.steps).toHaveLength(3); // start + 2 steps
|
||||||
|
expect(result.steps[1].role).toBe("roleA");
|
||||||
|
expect(result.steps[2].role).toBe("roleB");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdStepShow with completed threads", () => {
|
||||||
|
test("shows step detail from active thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-step-active",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "p",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Active thread response",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sess-active",
|
||||||
|
model: "model-x",
|
||||||
|
duration: 1234,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "coder",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000B1" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const result = await cmdStepShow(tmpDir, stepHash);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
sessionId: "sess-active",
|
||||||
|
model: "model-x",
|
||||||
|
duration: 1234,
|
||||||
|
turnCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows step detail from completed thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-step-completed",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "p",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Completed thread response",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sess-completed",
|
||||||
|
model: "model-y",
|
||||||
|
duration: 5678,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "reviewer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000B2" as ThreadId;
|
||||||
|
// Thread is NOT in threads.yaml
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
// But it IS in history.jsonl
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: stepHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cmdStepShow(tmpDir, stepHash);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
sessionId: "sess-completed",
|
||||||
|
model: "model-y",
|
||||||
|
duration: 5678,
|
||||||
|
turnCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdThreadRead with completed threads", () => {
|
||||||
|
test("reads completed thread context", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-read-completed",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
writer: {
|
||||||
|
description: "Write",
|
||||||
|
goal: "You are a writer.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Write content.",
|
||||||
|
output: "Summary.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Write something",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "writer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000C1" as ThreadId;
|
||||||
|
// Thread is NOT in threads.yaml
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
// But it IS in history.jsonl
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: stepHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
expect(markdown).toContain("writer");
|
||||||
|
expect(markdown).toContain("Write something");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads completed thread with before filter", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-read-before",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Do task",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "roleX",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "roleY",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step3Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step2Hash,
|
||||||
|
role: "roleZ",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000C2" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: step3Hash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(
|
||||||
|
tmpDir,
|
||||||
|
threadId,
|
||||||
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
|
step2Hash,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should contain step1 (roleX) but not step2 (roleY) or step3 (roleZ)
|
||||||
|
expect(markdown).toContain("roleX");
|
||||||
|
expect(markdown).not.toContain("roleY");
|
||||||
|
expect(markdown).not.toContain("roleZ");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
|||||||
name,
|
name,
|
||||||
description: "Test workflow",
|
description: "Test workflow",
|
||||||
roles: {},
|
roles: {},
|
||||||
conditions: {},
|
|
||||||
graph: {},
|
graph: {},
|
||||||
};
|
};
|
||||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||||
@@ -36,7 +35,6 @@ async function createWorkflowYaml(name: string, version: string | null = null):
|
|||||||
name,
|
name,
|
||||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||||
roles: {},
|
roles: {},
|
||||||
conditions: {},
|
|
||||||
graph: {},
|
graph: {},
|
||||||
};
|
};
|
||||||
const yaml = stringify(payload);
|
const yaml = stringify(payload);
|
||||||
@@ -145,7 +143,7 @@ describe("Strategy 2: File Path Resolution", () => {
|
|||||||
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
|
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
|
||||||
await makeUwfStore(storageRoot);
|
await makeUwfStore(storageRoot);
|
||||||
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
|
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
|
||||||
await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph");
|
await writeFile(yamlPath, "name: test\n# missing roles and graph");
|
||||||
|
|
||||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
||||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||||
import { cmdSkillCli } from "./commands/skill.js";
|
import { cmdSkillCli } from "./commands/skill.js";
|
||||||
import { cmdStepFork, cmdStepList, cmdStepShow } from "./commands/step.js";
|
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||||
import {
|
import {
|
||||||
cmdThreadCancel,
|
cmdThreadCancel,
|
||||||
cmdThreadExec,
|
cmdThreadExec,
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
THREAD_READ_DEFAULT_QUOTA,
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
type ThreadStatus,
|
type ThreadStatus,
|
||||||
} from "./commands/thread.js";
|
} from "./commands/thread.js";
|
||||||
|
import { parseTimeInput } from "./commands/thread-time-parser.js";
|
||||||
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
||||||
import { formatOutput, type OutputFormat } from "./format.js";
|
import { formatOutput, type OutputFormat } from "./format.js";
|
||||||
import { resolveStorageRoot } from "./store.js";
|
import { resolveStorageRoot } from "./store.js";
|
||||||
@@ -168,30 +169,103 @@ thread
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper functions for thread list command parsing
|
||||||
|
function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
|
||||||
|
if (status === undefined) return null;
|
||||||
|
const raw = status.trim();
|
||||||
|
if (raw === "active") return ["idle", "running"];
|
||||||
|
|
||||||
|
const parts = raw.split(",").map((s) => s.trim());
|
||||||
|
const validStatuses: ThreadStatus[] = ["idle", "running", "completed"];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!validStatuses.includes(part as ThreadStatus)) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Invalid status: ${part}. Must be one of: idle, running, completed, active\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts as ThreadStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeFilters(
|
||||||
|
after: string | undefined,
|
||||||
|
before: string | undefined,
|
||||||
|
nowMs: number,
|
||||||
|
): { afterMs: number | null; beforeMs: number | null } {
|
||||||
|
try {
|
||||||
|
const afterMs = after !== undefined ? parseTimeInput(after, nowMs) : null;
|
||||||
|
const beforeMs = before !== undefined ? parseTimeInput(before, nowMs) : null;
|
||||||
|
return { afterMs, beforeMs };
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePaginationOptions(
|
||||||
|
skip: string | undefined,
|
||||||
|
take: string | undefined,
|
||||||
|
): { skip: number | null; take: number | null } {
|
||||||
|
let skipVal: number | null = null;
|
||||||
|
let takeVal: number | null = null;
|
||||||
|
|
||||||
|
if (skip !== undefined) {
|
||||||
|
skipVal = Number.parseInt(skip, 10);
|
||||||
|
if (!Number.isInteger(skipVal) || skipVal < 0) {
|
||||||
|
process.stderr.write("--skip must be a non-negative integer\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (take !== undefined) {
|
||||||
|
takeVal = Number.parseInt(take, 10);
|
||||||
|
if (!Number.isInteger(takeVal) || takeVal < 1) {
|
||||||
|
process.stderr.write("--take must be a positive integer\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { skip: skipVal, take: takeVal };
|
||||||
|
}
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List threads")
|
.description("List threads")
|
||||||
.option("--status <status>", "Filter by status: idle, running, or completed")
|
.option(
|
||||||
.action((opts: { status: string | undefined }) => {
|
"--status <status>",
|
||||||
const storageRoot = resolveStorageRoot();
|
"Filter by status: idle, running, completed, active (idle+running), or comma-separated values",
|
||||||
runAction(async () => {
|
)
|
||||||
const validStatuses: ThreadStatus[] = ["idle", "running", "completed"];
|
.option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
|
||||||
let statusFilter: ThreadStatus | null = null;
|
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
|
||||||
|
.option("--skip <n>", "Skip first n threads")
|
||||||
|
.option("--take <n>", "Return at most n threads")
|
||||||
|
.action(
|
||||||
|
(opts: {
|
||||||
|
status: string | undefined;
|
||||||
|
after: string | undefined;
|
||||||
|
before: string | undefined;
|
||||||
|
skip: string | undefined;
|
||||||
|
take: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const statusFilter = parseStatusFilter(opts.status);
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const { afterMs, beforeMs } = parseTimeFilters(opts.after, opts.before, nowMs);
|
||||||
|
const { skip, take } = parsePaginationOptions(opts.skip, opts.take);
|
||||||
|
|
||||||
if (opts.status !== undefined) {
|
const result = await cmdThreadList(
|
||||||
if (!validStatuses.includes(opts.status as ThreadStatus)) {
|
storageRoot,
|
||||||
process.stderr.write(
|
statusFilter,
|
||||||
`Invalid status: ${opts.status}. Must be one of: idle, running, completed\n`,
|
afterMs,
|
||||||
);
|
beforeMs,
|
||||||
process.exit(1);
|
skip,
|
||||||
}
|
take,
|
||||||
statusFilter = opts.status as ThreadStatus;
|
);
|
||||||
}
|
writeOutput(result);
|
||||||
|
});
|
||||||
const result = await cmdThreadList(storageRoot, statusFilter);
|
},
|
||||||
writeOutput(result);
|
);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("stop")
|
.command("stop")
|
||||||
@@ -272,7 +346,23 @@ step
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// step read is not yet registered (half-baked, see step.ts cmdStepRead)
|
step
|
||||||
|
.command("read")
|
||||||
|
.description("Read a step's turns as human-readable markdown")
|
||||||
|
.argument("<step-hash>", "CAS hash of the StepNode")
|
||||||
|
.option("--quota <chars>", "Max output characters", "4000")
|
||||||
|
.action((stepHash: string, opts: { quota: string }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const quota = Number.parseInt(opts.quota, 10);
|
||||||
|
if (!Number.isFinite(quota) || quota < 1) {
|
||||||
|
process.stderr.write("invalid --quota: must be a positive integer\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota);
|
||||||
|
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
step
|
step
|
||||||
.command("fork")
|
.command("fork")
|
||||||
@@ -475,7 +565,11 @@ cas
|
|||||||
.action((hash: string) => {
|
.action((hash: string) => {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
runAction(async () => {
|
runAction(async () => {
|
||||||
writeOutput(await cmdCasHas(storageRoot, hash));
|
const result = await cmdCasHas(storageRoot, hash);
|
||||||
|
writeOutput(result);
|
||||||
|
if (!result.exists) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
StepNodePayload,
|
StepNodePayload,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
} from "@uncaged/workflow-protocol";
|
} from "@uncaged/workflow-protocol";
|
||||||
import { loadThreadsIndex, type UwfStore } from "../store.js";
|
import { findThreadInHistory, loadThreadsIndex, type UwfStore } from "../store.js";
|
||||||
|
|
||||||
type ChainState = {
|
type ChainState = {
|
||||||
startHash: CasRef;
|
startHash: CasRef;
|
||||||
@@ -203,11 +203,15 @@ function collectOrderedSteps(
|
|||||||
|
|
||||||
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
||||||
const index = await loadThreadsIndex(storageRoot);
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
const head = index[threadId];
|
const activeHead = index[threadId];
|
||||||
if (head === undefined) {
|
if (activeHead !== undefined) {
|
||||||
fail(`thread not active: ${threadId}`);
|
return activeHead;
|
||||||
}
|
}
|
||||||
return head;
|
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||||
|
if (hist !== null) {
|
||||||
|
return hist.head;
|
||||||
|
}
|
||||||
|
fail(`thread not found: ${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { BootstrapCapableStore } from "@uncaged/json-cas";
|
||||||
import type {
|
import type {
|
||||||
CasRef,
|
CasRef,
|
||||||
StartEntry,
|
StartEntry,
|
||||||
@@ -18,6 +19,11 @@ import {
|
|||||||
walkChain,
|
walkChain,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
|
|
||||||
|
type TurnData = {
|
||||||
|
index: number;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all steps in a thread (previously: thread steps)
|
* List all steps in a thread (previously: thread steps)
|
||||||
*/
|
*/
|
||||||
@@ -111,13 +117,114 @@ export async function cmdStepFork(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a step's agent output as markdown (new command - requires #462)
|
* Load and validate step detail node from CAS store
|
||||||
* TODO: Implement once unified agent detail/turn schema is available
|
*/
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
export async function cmdStepRead(
|
export async function cmdStepRead(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
stepHash: CasRef,
|
stepHash: CasRef,
|
||||||
_before: number | null = null,
|
quota: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const uwf = await createUwfStore(storageRoot);
|
const uwf = await createUwfStore(storageRoot);
|
||||||
const node = uwf.store.get(stepHash);
|
const node = uwf.store.get(stepHash);
|
||||||
@@ -128,18 +235,22 @@ export async function cmdStepRead(
|
|||||||
fail(`node ${stepHash} is not a StepNode`);
|
fail(`node ${stepHash} is not a StepNode`);
|
||||||
}
|
}
|
||||||
const payload = node.payload as StepNodePayload;
|
const payload = node.payload as StepNodePayload;
|
||||||
if (!payload.output) {
|
|
||||||
fail(`step ${stepHash} has no output`);
|
if (payload.detail === null) {
|
||||||
|
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement progressive turn reading with --before N
|
const detail = loadStepDetail(uwf.store, payload.detail);
|
||||||
// For now, return a placeholder
|
const turnData = loadTurnData(uwf.store, detail.turns);
|
||||||
const outputNode = uwf.store.get(payload.output);
|
|
||||||
if (outputNode === null) {
|
if (turnData.length === 0) {
|
||||||
fail(`output node not found: ${payload.output}`);
|
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the output as JSON for now
|
const headerSection = formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||||
// Once #462 is implemented, this will properly format frontmatter + markdown
|
const BUFFER = 200;
|
||||||
return JSON.stringify(outputNode.payload, null, 2);
|
const availableQuota = quota - headerSection.length - BUFFER;
|
||||||
|
const selectedTurns = selectTurnsForQuota(turnData, availableQuota);
|
||||||
|
|
||||||
|
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Parse time input: ISO date (YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS) or relative (7d, 24h, 30m)
|
||||||
|
* Returns Unix timestamp in milliseconds.
|
||||||
|
*/
|
||||||
|
export function parseTimeInput(input: string, nowMs: number): number {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Relative time: 7d, 24h, 30m
|
||||||
|
const relativeMatch = /^(\d+)(d|h|m)$/.exec(trimmed);
|
||||||
|
if (relativeMatch !== null) {
|
||||||
|
const value = Number.parseInt(relativeMatch[1], 10);
|
||||||
|
const unit = relativeMatch[2];
|
||||||
|
const multiplier = unit === "d" ? 86400000 : unit === "h" ? 3600000 : 60000;
|
||||||
|
return nowMs - value * multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO date: try parsing
|
||||||
|
const parsed = Date.parse(trimmed);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new Error(`invalid time format: ${trimmed} (expected ISO date or relative like '7d')`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
@@ -8,20 +8,24 @@ import type {
|
|||||||
AgentAlias,
|
AgentAlias,
|
||||||
AgentConfig,
|
AgentConfig,
|
||||||
CasRef,
|
CasRef,
|
||||||
ModeratorContext,
|
|
||||||
StartNodePayload,
|
StartNodePayload,
|
||||||
StartOutput,
|
StartOutput,
|
||||||
StepContext,
|
|
||||||
StepNodePayload,
|
StepNodePayload,
|
||||||
StepOutput,
|
StepOutput,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
|
ThreadsIndex,
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
WorkflowPayload,
|
WorkflowPayload,
|
||||||
} from "@uncaged/workflow-protocol";
|
} from "@uncaged/workflow-protocol";
|
||||||
import { createProcessLogger, generateUlid, type ProcessLogger } from "@uncaged/workflow-util";
|
import {
|
||||||
|
createProcessLogger,
|
||||||
|
extractUlidTimestamp,
|
||||||
|
generateUlid,
|
||||||
|
type ProcessLogger,
|
||||||
|
} from "@uncaged/workflow-util";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
import { parse, stringify } from "yaml";
|
import { parse } from "yaml";
|
||||||
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
|
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
|
||||||
import {
|
import {
|
||||||
appendThreadHistory,
|
appendThreadHistory,
|
||||||
@@ -47,6 +51,7 @@ import {
|
|||||||
import { materializeWorkflowPayload } from "./workflow.js";
|
import { materializeWorkflowPayload } from "./workflow.js";
|
||||||
|
|
||||||
const END_ROLE = "$END";
|
const END_ROLE = "$END";
|
||||||
|
const START_ROLE = "$START";
|
||||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||||
|
|
||||||
const PL_THREAD_START = "7HNQ4B2X";
|
const PL_THREAD_START = "7HNQ4B2X";
|
||||||
@@ -344,63 +349,115 @@ async function threadListItemFromActive(
|
|||||||
return { thread: threadId, workflow, head, status };
|
return { thread: threadId, workflow, head, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdThreadList(
|
async function collectActiveThreads(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
statusFilter: ThreadStatus | null,
|
uwf: UwfStore,
|
||||||
|
index: ThreadsIndex,
|
||||||
): Promise<ThreadListItemWithStatus[]> {
|
): Promise<ThreadListItemWithStatus[]> {
|
||||||
const uwf = await createUwfStore(storageRoot);
|
|
||||||
const index = await loadThreadsIndex(storageRoot);
|
|
||||||
const items: ThreadListItemWithStatus[] = [];
|
const items: ThreadListItemWithStatus[] = [];
|
||||||
|
|
||||||
// Add active threads
|
|
||||||
for (const [threadId, head] of Object.entries(index)) {
|
for (const [threadId, head] of Object.entries(index)) {
|
||||||
const item = await threadListItemFromActive(storageRoot, uwf, threadId as ThreadId, head);
|
const item = await threadListItemFromActive(
|
||||||
|
storageRoot,
|
||||||
|
uwf,
|
||||||
|
threadId as ThreadId,
|
||||||
|
head as CasRef,
|
||||||
|
);
|
||||||
if (item !== null) {
|
if (item !== null) {
|
||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add completed threads if requested
|
|
||||||
if (statusFilter === "completed" || statusFilter === null) {
|
|
||||||
const activeIds = new Set(items.map((i) => i.thread));
|
|
||||||
const history = await loadThreadHistory(storageRoot);
|
|
||||||
for (const entry of history) {
|
|
||||||
if (!activeIds.has(entry.thread)) {
|
|
||||||
items.push({
|
|
||||||
thread: entry.thread,
|
|
||||||
workflow: entry.workflow,
|
|
||||||
head: entry.head,
|
|
||||||
status: "completed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply status filter if provided
|
|
||||||
if (statusFilter !== null) {
|
|
||||||
return items.filter((item) => item.status === statusFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatYaml(value: unknown): string {
|
async function collectCompletedThreads(
|
||||||
return stringify(value, { aliasDuplicateObjects: false }).trimEnd();
|
storageRoot: string,
|
||||||
|
activeIds: Set<ThreadId>,
|
||||||
|
): Promise<ThreadListItemWithStatus[]> {
|
||||||
|
const items: ThreadListItemWithStatus[] = [];
|
||||||
|
const history = await loadThreadHistory(storageRoot);
|
||||||
|
const seen = new Set<ThreadId>(); // Deduplication (issue #470)
|
||||||
|
for (const entry of history) {
|
||||||
|
if (!activeIds.has(entry.thread) && !seen.has(entry.thread)) {
|
||||||
|
seen.add(entry.thread);
|
||||||
|
items.push({
|
||||||
|
thread: entry.thread,
|
||||||
|
workflow: entry.workflow,
|
||||||
|
head: entry.head,
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
|
function applyTimeFilters(
|
||||||
return [
|
items: ThreadListItemWithStatus[],
|
||||||
`## Step ${index}: ${item.payload.role}`,
|
afterMs: number | null,
|
||||||
"",
|
beforeMs: number | null,
|
||||||
`- **Hash:** \`${item.hash}\``,
|
): ThreadListItemWithStatus[] {
|
||||||
`- **Agent:** ${item.payload.agent}`,
|
if (afterMs === null && beforeMs === null) return items;
|
||||||
"",
|
return items.filter((item) => {
|
||||||
"### Output",
|
const ts = extractUlidTimestamp(item.thread);
|
||||||
"",
|
if (ts === null) return false;
|
||||||
"```yaml",
|
if (afterMs !== null && ts <= afterMs) return false;
|
||||||
outputYaml,
|
if (beforeMs !== null && ts >= beforeMs) return false;
|
||||||
"```",
|
return true;
|
||||||
].join("\n");
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByNewestFirst(items: ThreadListItemWithStatus[]): ThreadListItemWithStatus[] {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const tsA = extractUlidTimestamp(a.thread) ?? 0;
|
||||||
|
const tsB = extractUlidTimestamp(b.thread) ?? 0;
|
||||||
|
return tsB - tsA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPagination(
|
||||||
|
items: ThreadListItemWithStatus[],
|
||||||
|
skip: number | null,
|
||||||
|
take: number | null,
|
||||||
|
): ThreadListItemWithStatus[] {
|
||||||
|
const skipCount = skip ?? 0;
|
||||||
|
const takeCount = take ?? items.length;
|
||||||
|
return items.slice(skipCount, skipCount + takeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadList(
|
||||||
|
storageRoot: string,
|
||||||
|
statusFilter: ThreadStatus[] | null,
|
||||||
|
afterMs: number | null,
|
||||||
|
beforeMs: number | null,
|
||||||
|
skip: number | null,
|
||||||
|
take: number | null,
|
||||||
|
): Promise<ThreadListItemWithStatus[]> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
|
||||||
|
// Collect active threads
|
||||||
|
let items = await collectActiveThreads(storageRoot, uwf, index);
|
||||||
|
|
||||||
|
// Collect completed threads (if relevant for status filter)
|
||||||
|
const includeCompleted = statusFilter === null || statusFilter.includes("completed");
|
||||||
|
if (includeCompleted) {
|
||||||
|
const activeIds = new Set(items.map((i) => i.thread));
|
||||||
|
const completedItems = await collectCompletedThreads(storageRoot, activeIds);
|
||||||
|
items = items.concat(completedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply status filter
|
||||||
|
if (statusFilter !== null) {
|
||||||
|
items = items.filter((item) => statusFilter.includes(item.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply time range filters
|
||||||
|
items = applyTimeFilters(items, afterMs, beforeMs);
|
||||||
|
|
||||||
|
// Sort by timestamp descending (newest first)
|
||||||
|
items = sortByNewestFirst(items);
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
return applyPagination(items, skip, take);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
|
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
|
||||||
@@ -446,22 +503,60 @@ function sliceBeforeHash(
|
|||||||
return candidates.slice(0, idx);
|
return candidates.slice(0, idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calculateFormattedStepLength(
|
||||||
|
stepNum: number,
|
||||||
|
item: OrderedStepItem,
|
||||||
|
uwf: UwfStore,
|
||||||
|
workflow: WorkflowPayload,
|
||||||
|
): number {
|
||||||
|
// Calculate using the same format as formatStepHeader, formatStepPrompt, formatStepContent
|
||||||
|
// Use a temporary set to avoid mutating the actual shownPromptRoles during calculation
|
||||||
|
const tempShownRoles = new Set<string>();
|
||||||
|
const header = formatStepHeader(stepNum, item);
|
||||||
|
const roleDef = workflow.roles[item.payload.role];
|
||||||
|
const prompt = formatStepPrompt(roleDef, item.payload.role, tempShownRoles);
|
||||||
|
const content = formatStepContent(uwf, item);
|
||||||
|
|
||||||
|
const stepBlock = [header, prompt, content].filter((s) => s !== "").join("");
|
||||||
|
|
||||||
|
// Don't add separator here - it will be counted when we know the final structure
|
||||||
|
return stepBlock.length;
|
||||||
|
}
|
||||||
|
|
||||||
function selectByQuota(
|
function selectByQuota(
|
||||||
candidates: OrderedStepItem[],
|
candidates: OrderedStepItem[],
|
||||||
uwf: UwfStore,
|
uwf: UwfStore,
|
||||||
|
workflow: WorkflowPayload,
|
||||||
quota: number,
|
quota: number,
|
||||||
|
startSectionLength: number,
|
||||||
): { selected: OrderedStepItem[]; skippedCount: number } {
|
): { selected: OrderedStepItem[]; skippedCount: number } {
|
||||||
const selected: OrderedStepItem[] = [];
|
const selected: OrderedStepItem[] = [];
|
||||||
let totalChars = 0;
|
|
||||||
|
// Start with start section length
|
||||||
|
let totalChars = startSectionLength;
|
||||||
|
|
||||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||||
const item = candidates[i];
|
const item = candidates[i];
|
||||||
if (item === undefined) continue;
|
if (item === undefined) continue;
|
||||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
|
||||||
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
// Calculate the actual formatted length using the same format as final output
|
||||||
|
const blockLen = calculateFormattedStepLength(i + 1, item, uwf, workflow);
|
||||||
|
|
||||||
|
// Calculate cost of adding this step:
|
||||||
|
// - blockLen: the step content
|
||||||
|
// - 6: separator before this step (if there are already parts)
|
||||||
|
const separatorCost = totalChars > 0 || selected.length > 0 ? 6 : 0;
|
||||||
|
const addCost = blockLen + separatorCost;
|
||||||
|
|
||||||
|
// Check quota BEFORE adding - but always include at least one step
|
||||||
|
if (totalChars + addCost > quota && selected.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
selected.unshift(item);
|
selected.unshift(item);
|
||||||
totalChars += blockLen;
|
totalChars += addCost;
|
||||||
if (totalChars > quota) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { selected, skippedCount: candidates.length - selected.length };
|
return { selected, skippedCount: candidates.length - selected.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,11 +623,21 @@ function formatThreadReadMarkdown(options: {
|
|||||||
const { ordered, uwf, workflow, quota, before } = options;
|
const { ordered, uwf, workflow, quota, before } = options;
|
||||||
|
|
||||||
const candidates = before !== null ? sliceBeforeHash(ordered, before, options.threadId) : ordered;
|
const candidates = before !== null ? sliceBeforeHash(ordered, before, options.threadId) : ordered;
|
||||||
const { selected, skippedCount } = selectByQuota(candidates, uwf, quota);
|
|
||||||
|
// Calculate start section length for quota accounting
|
||||||
|
const startSection = formatStartSection(options);
|
||||||
|
const startSectionLength = startSection !== "" ? startSection.length : 0;
|
||||||
|
|
||||||
|
const { selected, skippedCount } = selectByQuota(
|
||||||
|
candidates,
|
||||||
|
uwf,
|
||||||
|
workflow,
|
||||||
|
quota,
|
||||||
|
startSectionLength,
|
||||||
|
);
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
const startSection = formatStartSection(options);
|
|
||||||
if (startSection !== "") parts.push(startSection);
|
if (startSection !== "") parts.push(startSection);
|
||||||
|
|
||||||
if (skippedCount > 0 && selected.length > 0) {
|
if (skippedCount > 0 && selected.length > 0) {
|
||||||
@@ -564,16 +669,32 @@ function formatThreadReadMarkdown(options: {
|
|||||||
return parts.join("\n\n---\n\n");
|
return parts.join("\n\n---\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
type EvaluateLastOutput = Record<string, unknown> & { status: string };
|
||||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
|
||||||
const steps: StepContext[] = chronological.map((step) => ({
|
function resolveEvaluateArgs(
|
||||||
role: step.role,
|
uwf: UwfStore,
|
||||||
output: expandOutput(uwf, step.output),
|
chain: ChainState,
|
||||||
detail: step.detail,
|
): { lastRole: string; lastOutput: EvaluateLastOutput } {
|
||||||
agent: step.agent,
|
if (chain.headIsStart) {
|
||||||
edgePrompt: step.edgePrompt ?? "",
|
return { lastRole: START_ROLE, lastOutput: { status: "_" } };
|
||||||
}));
|
}
|
||||||
return { start: chain.start, steps };
|
|
||||||
|
const lastStep = chain.stepsNewestFirst[0];
|
||||||
|
if (lastStep === undefined) {
|
||||||
|
fail("empty step chain");
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = expandOutput(uwf, lastStep.output);
|
||||||
|
const base =
|
||||||
|
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||||
|
? (raw as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const status = typeof base.status === "string" ? base.status : "_";
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastRole: lastStep.role,
|
||||||
|
lastOutput: { ...base, status },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||||
@@ -817,9 +938,9 @@ async function cmdThreadStepOnce(
|
|||||||
const chain = walkChain(uwf, headHash);
|
const chain = walkChain(uwf, headHash);
|
||||||
const workflowHash = chain.start.workflow;
|
const workflowHash = chain.start.workflow;
|
||||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||||
const context = buildModeratorContext(uwf, chain);
|
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
|
||||||
|
|
||||||
const nextResult = await evaluate(workflow, context);
|
const nextResult = evaluate(workflow.graph, lastRole, lastOutput);
|
||||||
if (!nextResult.ok) {
|
if (!nextResult.ok) {
|
||||||
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
|
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
|
||||||
}
|
}
|
||||||
@@ -869,8 +990,11 @@ async function cmdThreadStepOnce(
|
|||||||
await saveThreadsIndex(storageRoot, freshIndex);
|
await saveThreadsIndex(storageRoot, freshIndex);
|
||||||
|
|
||||||
const chainAfter = walkChain(uwfAfter, newHead);
|
const chainAfter = walkChain(uwfAfter, newHead);
|
||||||
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
const { lastRole: lastRoleAfter, lastOutput: lastOutputAfter } = resolveEvaluateArgs(
|
||||||
const afterResult = await evaluate(workflow, contextAfter);
|
uwfAfter,
|
||||||
|
chainAfter,
|
||||||
|
);
|
||||||
|
const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
|
||||||
if (!afterResult.ok) {
|
if (!afterResult.ok) {
|
||||||
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
|
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,7 @@ import { readFile } from "node:fs/promises";
|
|||||||
|
|
||||||
import type { JSONSchema } from "@uncaged/json-cas";
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
import { putSchema, validate } from "@uncaged/json-cas";
|
import { putSchema, validate } from "@uncaged/json-cas";
|
||||||
import type {
|
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
CasRef,
|
|
||||||
RoleDefinition,
|
|
||||||
Transition,
|
|
||||||
WorkflowPayload,
|
|
||||||
} from "@uncaged/workflow-protocol";
|
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -51,20 +46,23 @@ function isJsonSchema(value: unknown): value is JSONSchema {
|
|||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
|
/** Normalize graph: validate each status → target mapping. */
|
||||||
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
|
function normalizeGraph(
|
||||||
const result: Record<string, Transition[]> = {};
|
graph: Record<string, Record<string, Target>>,
|
||||||
for (const [node, transitions] of Object.entries(graph)) {
|
): Record<string, Record<string, Target>> {
|
||||||
result[node] = transitions.map((t) => {
|
const result: Record<string, Record<string, Target>> = {};
|
||||||
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
|
for (const [node, statusMap] of Object.entries(graph)) {
|
||||||
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
|
const normalized: Record<string, Target> = {};
|
||||||
|
for (const [status, target] of Object.entries(statusMap)) {
|
||||||
|
if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
|
||||||
|
fail(`graph[${node}][${status}] → "${target.role}": prompt is required (non-empty string)`);
|
||||||
}
|
}
|
||||||
return {
|
normalized[status] = {
|
||||||
role: t.role,
|
role: target.role,
|
||||||
condition: t.condition ?? null,
|
prompt: target.prompt,
|
||||||
prompt: t.prompt,
|
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
result[node] = normalized;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -106,7 +104,6 @@ export async function materializeWorkflowPayload(
|
|||||||
name: raw.name,
|
name: raw.name,
|
||||||
description: raw.description,
|
description: raw.description,
|
||||||
roles,
|
roles,
|
||||||
conditions: raw.conditions,
|
|
||||||
graph: normalizeGraph(raw.graph),
|
graph: normalizeGraph(raw.graph),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,23 +30,12 @@ function isRoleDefinition(value: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isConditionDefinition(value: unknown): boolean {
|
function isTarget(value: unknown): boolean {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return typeof value.description === "string" && typeof value.expression === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTransition(value: unknown): boolean {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const condition = value.condition;
|
|
||||||
return (
|
return (
|
||||||
typeof value.role === "string" &&
|
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
|
||||||
typeof value.prompt === "string" &&
|
|
||||||
value.prompt.trim() !== "" &&
|
|
||||||
(condition === null || condition === undefined || typeof condition === "string")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +51,7 @@ function isGraph(value: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return Object.values(value).every(
|
return Object.values(value).every(
|
||||||
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
|
(statusMap) => isRecord(statusMap) && Object.values(statusMap).every((t) => isTarget(t)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,11 +90,7 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
|||||||
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (
|
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
|
||||||
!isStringRecord(raw.roles, isRoleDefinition) ||
|
|
||||||
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
|
||||||
!isGraph(raw.graph)
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return raw as WorkflowPayload;
|
return raw as WorkflowPayload;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("buildClaudeCodePrompt", () => {
|
|||||||
expect(result).toContain("## Task\nFix the bug");
|
expect(result).toContain("## Task\nFix the bug");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("includes previous steps as history summary", () => {
|
test("includes previous steps with content on first visit", () => {
|
||||||
const ctx = makeCtx({
|
const ctx = makeCtx({
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
@@ -48,18 +48,50 @@ describe("buildClaudeCodePrompt", () => {
|
|||||||
agent: "hermes",
|
agent: "hermes",
|
||||||
detail: "detail-1",
|
detail: "detail-1",
|
||||||
edgePrompt: "Create a plan.",
|
edgePrompt: "Create a plan.",
|
||||||
|
content: "Here is my detailed plan for doing X.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const result = buildClaudeCodePrompt(ctx);
|
const result = buildClaudeCodePrompt(ctx);
|
||||||
expect(result).toContain("## Previous Steps");
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
expect(result).toContain("Step 1: planner");
|
expect(result).toContain("Step 1: planner");
|
||||||
expect(result).toContain("do X");
|
expect(result).toContain("do X");
|
||||||
|
// First visit should include step content
|
||||||
|
expect(result).toContain("Here is my detailed plan for doing X.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-entry shows steps since last visit without content", () => {
|
||||||
|
const ctx = makeCtx({
|
||||||
|
isFirstVisit: false,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: '{"status":"done"}',
|
||||||
|
agent: "claude-code",
|
||||||
|
detail: "detail-1",
|
||||||
|
edgePrompt: "Implement.",
|
||||||
|
content: "I implemented everything.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: '{"approved":false}',
|
||||||
|
agent: "claude-code",
|
||||||
|
detail: "detail-2",
|
||||||
|
edgePrompt: "Review.",
|
||||||
|
content: "Rejected: complexity too high, refactor cmdStepRead.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = buildClaudeCodePrompt(ctx);
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("reviewer");
|
||||||
|
expect(result).toContain("approved");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("omits history section when steps array is empty", () => {
|
test("omits history section when steps array is empty", () => {
|
||||||
const result = buildClaudeCodePrompt(makeCtx({ steps: [] }));
|
const result = buildClaudeCodePrompt(makeCtx({ steps: [] }));
|
||||||
expect(result).not.toContain("## Previous Steps");
|
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("## Current Instruction");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("works without outputFormatInstruction", () => {
|
test("works without outputFormatInstruction", () => {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.4.0",
|
"@uncaged/json-cas": "^0.4.0",
|
||||||
"@uncaged/workflow-agent-kit": "workspace:^"
|
"@uncaged/workflow-agent-kit": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Store } from "@uncaged/json-cas";
|
|||||||
import {
|
import {
|
||||||
type AgentContext,
|
type AgentContext,
|
||||||
type AgentRunResult,
|
type AgentRunResult,
|
||||||
|
buildContinuationPrompt,
|
||||||
buildRolePrompt,
|
buildRolePrompt,
|
||||||
createAgent,
|
createAgent,
|
||||||
getCachedSessionId,
|
getCachedSessionId,
|
||||||
@@ -18,25 +19,6 @@ const CLAUDE_COMMAND = "claude";
|
|||||||
const CLAUDE_MAX_TURNS = 90;
|
const CLAUDE_MAX_TURNS = 90;
|
||||||
const CLAUDE_MODEL = process.env.CLAUDE_MODEL ?? null;
|
const CLAUDE_MODEL = process.env.CLAUDE_MODEL ?? null;
|
||||||
|
|
||||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
|
||||||
if (steps.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = ["## Previous Steps"];
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
|
||||||
const step = steps[i];
|
|
||||||
if (step === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`### Step ${i + 1}: ${step.role}`);
|
|
||||||
lines.push(`Output: ${JSON.stringify(step.output)}`);
|
|
||||||
lines.push(`Agent: ${step.agent}`);
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assemble system prompt, task, and prior step outputs for Claude Code. */
|
/** Assemble system prompt, task, and prior step outputs for Claude Code. */
|
||||||
export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
||||||
const roleDef = ctx.workflow.roles[ctx.role];
|
const roleDef = ctx.workflow.roles[ctx.role];
|
||||||
@@ -46,11 +28,23 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
|||||||
parts.push(ctx.outputFormatInstruction, "");
|
parts.push(ctx.outputFormatInstruction, "");
|
||||||
}
|
}
|
||||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||||
const historyBlock = buildHistorySummary(ctx.steps);
|
|
||||||
if (historyBlock !== "") {
|
if (!ctx.isFirstVisit) {
|
||||||
parts.push("", historyBlock);
|
// Re-entry (session will be resumed): show only steps since last visit, meta only
|
||||||
|
parts.push("", buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
||||||
|
} else if (ctx.steps.length > 0) {
|
||||||
|
// First visit: show all steps with content for recent ones
|
||||||
|
parts.push(
|
||||||
|
"",
|
||||||
|
buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt, {
|
||||||
|
includeContent: true,
|
||||||
|
quota: 32000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
|
||||||
}
|
}
|
||||||
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
|
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
|
|||||||
graph: {},
|
graph: {},
|
||||||
},
|
},
|
||||||
role: "developer",
|
role: "developer",
|
||||||
start: { prompt: "Fix the bug", workflowHash: "abc123", threadId: "t1" },
|
start: { prompt: "Fix the bug", workflow: "abc123" },
|
||||||
steps: [],
|
steps: [],
|
||||||
store: {} as AgentContext["store"],
|
store: {} as AgentContext["store"],
|
||||||
outputFormatInstruction: "Use YAML frontmatter",
|
outputFormatInstruction: "Use YAML frontmatter",
|
||||||
@@ -55,6 +55,7 @@ describe("buildHermesPrompt", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
detail: "detail-1",
|
detail: "detail-1",
|
||||||
edgePrompt: "Implement the fix.",
|
edgePrompt: "Implement the fix.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "reviewer",
|
role: "reviewer",
|
||||||
@@ -62,6 +63,7 @@ describe("buildHermesPrompt", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
detail: "detail-2",
|
detail: "detail-2",
|
||||||
edgePrompt: "Review the code.",
|
edgePrompt: "Review the code.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -85,6 +87,7 @@ describe("buildHermesPrompt", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
detail: "detail-1",
|
detail: "detail-1",
|
||||||
edgePrompt: "First attempt.",
|
edgePrompt: "First attempt.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
edgePrompt: "Retry with a fresh approach.",
|
edgePrompt: "Retry with a fresh approach.",
|
||||||
@@ -95,4 +98,90 @@ describe("buildHermesPrompt", () => {
|
|||||||
expect(result).toContain("Retry with a fresh approach.");
|
expect(result).toContain("Retry with a fresh approach.");
|
||||||
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("first visit includes content from previous steps", () => {
|
||||||
|
const ctx = makeCtx({
|
||||||
|
isFirstVisit: true,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash1" },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-1",
|
||||||
|
edgePrompt: "Create the plan.",
|
||||||
|
content: "# Plan\nDetailed plan markdown...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-2",
|
||||||
|
edgePrompt: "Implement the code.",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-3",
|
||||||
|
edgePrompt: "Review the work.",
|
||||||
|
content: "# Review\nApproved!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
role: "committer",
|
||||||
|
edgePrompt: "Commit the reviewed code.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildHermesPrompt(ctx);
|
||||||
|
|
||||||
|
expect(result).toContain("Use YAML frontmatter");
|
||||||
|
expect(result).toContain("## Task");
|
||||||
|
expect(result).toContain("Fix the bug");
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 1: planner");
|
||||||
|
expect(result).toContain("#### Step Content");
|
||||||
|
expect(result).toContain("# Plan");
|
||||||
|
expect(result).toContain("Detailed plan markdown");
|
||||||
|
expect(result).toContain("### Step 2: developer");
|
||||||
|
expect(result).toContain("# Implementation");
|
||||||
|
expect(result).toContain("### Step 3: reviewer");
|
||||||
|
expect(result).toContain("# Review");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Commit the reviewed code.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-entry omits content from previous steps", () => {
|
||||||
|
const ctx = makeCtx({
|
||||||
|
isFirstVisit: false,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-1",
|
||||||
|
edgePrompt: "Implement the code.",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-2",
|
||||||
|
edgePrompt: "Review the work.",
|
||||||
|
content: "# Review\nNot approved!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
role: "developer",
|
||||||
|
edgePrompt: "Fix the issues.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildHermesPrompt(ctx);
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 2: reviewer");
|
||||||
|
expect(result).toContain(JSON.stringify({ approved: false }));
|
||||||
|
expect(result).not.toContain("#### Step Content");
|
||||||
|
expect(result).not.toContain("# Review");
|
||||||
|
expect(result).not.toContain("Not approved!");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,53 +14,39 @@ import { storeHermesSessionDetail } from "./session-detail.js";
|
|||||||
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||||
if (steps.length === 0) {
|
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = ["## Previous Steps"];
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
|
||||||
const step = steps[i];
|
|
||||||
if (step === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`### Step ${i + 1}: ${step.role}`);
|
|
||||||
lines.push(`Output: ${JSON.stringify(step.output)}`);
|
|
||||||
lines.push(`Agent: ${step.agent}`);
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInitialPrompt(ctx: AgentContext): string {
|
|
||||||
const roleDef = ctx.workflow.roles[ctx.role];
|
|
||||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (ctx.outputFormatInstruction !== "") {
|
if (ctx.outputFormatInstruction !== "") {
|
||||||
parts.push(ctx.outputFormatInstruction, "");
|
parts.push(ctx.outputFormatInstruction, "");
|
||||||
}
|
}
|
||||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
|
||||||
const historyBlock = buildHistorySummary(ctx.steps);
|
|
||||||
if (historyBlock !== "") {
|
|
||||||
parts.push("", historyBlock);
|
|
||||||
}
|
|
||||||
parts.push("", "## Moderator Instruction", "", ctx.edgePrompt);
|
|
||||||
return parts.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
|
||||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
|
||||||
if (!ctx.isFirstVisit) {
|
if (!ctx.isFirstVisit) {
|
||||||
const parts: string[] = [];
|
// Re-entry: show only steps since last visit, meta only
|
||||||
if (ctx.outputFormatInstruction !== "") {
|
|
||||||
parts.push(ctx.outputFormatInstruction, "");
|
|
||||||
}
|
|
||||||
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildInitialPrompt(ctx);
|
// First visit: show initial context with content for recent steps
|
||||||
|
const roleDef = ctx.workflow.roles[ctx.role];
|
||||||
|
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||||
|
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||||
|
|
||||||
|
// Add history with content (last 2-3 steps within quota)
|
||||||
|
if (ctx.steps.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
"",
|
||||||
|
buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt, {
|
||||||
|
includeContent: true,
|
||||||
|
quota: 32000, // Use THREAD_READ_DEFAULT_QUOTA equivalent
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push("", "## Moderator Instruction", "", ctx.edgePrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storePromptResult(
|
async function storePromptResult(
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ Requires `UWF_EDGE_PROMPT` in the environment (set by `uwf thread step`).
|
|||||||
function buildRolePrompt(role: RoleDefinition): string
|
function buildRolePrompt(role: RoleDefinition): string
|
||||||
function buildOutputFormatInstruction(schema: JSONSchema): string
|
function buildOutputFormatInstruction(schema: JSONSchema): string
|
||||||
function buildContinuationPrompt(
|
function buildContinuationPrompt(
|
||||||
ctx: AgentContext,
|
steps: StepContext[],
|
||||||
priorOutput: string,
|
role: string,
|
||||||
instruction: string,
|
edgePrompt: string,
|
||||||
|
options?: { includeContent?: boolean; quota?: number },
|
||||||
): string
|
): string
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const reviewerStep: StepContext = {
|
|||||||
detail: "2MXBG6PN4A8JR",
|
detail: "2MXBG6PN4A8JR",
|
||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
edgePrompt: "Review the developer's work.",
|
edgePrompt: "Review the developer's work.",
|
||||||
|
content: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const developerStep: StepContext = {
|
const developerStep: StepContext = {
|
||||||
@@ -16,6 +17,7 @@ const developerStep: StepContext = {
|
|||||||
detail: "1VPBG9SM5E7WK",
|
detail: "1VPBG9SM5E7WK",
|
||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
edgePrompt: "Implement the fix.",
|
edgePrompt: "Implement the fix.",
|
||||||
|
content: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("buildContinuationPrompt", () => {
|
describe("buildContinuationPrompt", () => {
|
||||||
@@ -29,6 +31,7 @@ describe("buildContinuationPrompt", () => {
|
|||||||
detail: "7BQST3VW9F2MA",
|
detail: "7BQST3VW9F2MA",
|
||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
edgePrompt: "Revise the plan.",
|
edgePrompt: "Revise the plan.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -70,4 +73,162 @@ describe("buildContinuationPrompt", () => {
|
|||||||
expect(result).toContain("## Moderator Instruction");
|
expect(result).toContain("## Moderator Instruction");
|
||||||
expect(result).toContain("Please revise your work.");
|
expect(result).toContain("Please revise your work.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("includes step content when includeContent option is true", () => {
|
||||||
|
const stepsWithContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash123" },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Plan\nDetailed plan markdown...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { filesChanged: ["app.ts"] },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
detail: "detail3",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nFeedback...",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(stepsWithContent, "committer", "Commit the changes.", {
|
||||||
|
includeContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 1: planner");
|
||||||
|
expect(result).toContain("#### Step Content");
|
||||||
|
expect(result).toContain("# Plan");
|
||||||
|
expect(result).toContain("Detailed plan markdown");
|
||||||
|
expect(result).toContain("### Step 2: developer");
|
||||||
|
expect(result).toContain("# Implementation");
|
||||||
|
expect(result).toContain("### Step 3: reviewer");
|
||||||
|
expect(result).toContain("# Review");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Commit the changes.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits step content when includeContent is false (default)", () => {
|
||||||
|
const stepsWithContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { filesChanged: ["app.ts"] },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nFeedback...",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(stepsWithContent, "developer", "Fix the issues.");
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 2: reviewer");
|
||||||
|
expect(result).toContain(JSON.stringify(stepsWithContent[1]?.output));
|
||||||
|
expect(result).not.toContain("#### Step Content");
|
||||||
|
expect(result).not.toContain("# Review");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("respects quota when includeContent is true", () => {
|
||||||
|
const largeContent = "x".repeat(5000);
|
||||||
|
const stepsWithContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash1" },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: largeContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: largeContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
detail: "detail3",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nLooks good!",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(stepsWithContent, "committer", "Commit the changes.", {
|
||||||
|
includeContent: true,
|
||||||
|
quota: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should include most recent step(s) within quota
|
||||||
|
expect(result).toContain("### Step 1: reviewer"); // Showing 1 of 3, so step 3 becomes step 1
|
||||||
|
expect(result).toContain("#### Step Content");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Showing 1 of 3 steps (2 omitted due to quota)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null content gracefully when includeContent is true", () => {
|
||||||
|
const stepsWithMixedContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash1" },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Plan\nDetails...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: null, // No content available
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
detail: "detail3",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nApproved!",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(
|
||||||
|
stepsWithMixedContent,
|
||||||
|
"committer",
|
||||||
|
"Commit the changes.",
|
||||||
|
{ includeContent: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain("### Step 1: planner");
|
||||||
|
expect(result).toContain("# Plan");
|
||||||
|
expect(result).toContain("### Step 2: developer");
|
||||||
|
// Step 2 should not have content section since content is null
|
||||||
|
expect(result).toContain("### Step 3: reviewer");
|
||||||
|
expect(result).toContain("# Review");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
// We need to test buildHistory indirectly through buildContext
|
||||||
|
// since buildHistory is not exported. For now, we'll test the integration
|
||||||
|
// through the public API in a separate integration test.
|
||||||
|
|
||||||
|
describe("context module - content extraction", () => {
|
||||||
|
test("placeholder - content extraction will be tested via integration tests", () => {
|
||||||
|
// This test is a placeholder. The actual testing of content extraction
|
||||||
|
// will be done through integration tests in build-continuation-prompt.test.ts
|
||||||
|
// where we can verify that StepContext objects have the correct content field.
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { StepContext } from "@uncaged/workflow-protocol";
|
import type { StepContext } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
function formatStep(step: StepContext, stepNumber: number): string {
|
function formatStep(step: StepContext, stepNumber: number, includeContent: boolean): string {
|
||||||
return [
|
const lines = [
|
||||||
`### Step ${stepNumber}: ${step.role}`,
|
`### Step ${stepNumber}: ${step.role}`,
|
||||||
`Output: ${JSON.stringify(step.output)}`,
|
`Output: ${JSON.stringify(step.output)}`,
|
||||||
`Agent: ${step.agent}`,
|
`Agent: ${step.agent}`,
|
||||||
].join("\n");
|
];
|
||||||
|
|
||||||
|
if (includeContent && step.content !== null) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("#### Step Content");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(step.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLastRoleIndex(steps: StepContext[], role: string): number {
|
function findLastRoleIndex(steps: StepContext[], role: string): number {
|
||||||
@@ -18,6 +27,45 @@ function findLastRoleIndex(steps: StepContext[], role: string): number {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectStepsWithinQuota(steps: StepContext[], quota: number): StepContext[] {
|
||||||
|
const selected: StepContext[] = [];
|
||||||
|
let totalChars = 0;
|
||||||
|
|
||||||
|
// Work backwards (newest first)
|
||||||
|
for (let i = steps.length - 1; i >= 0; i--) {
|
||||||
|
const step = steps[i];
|
||||||
|
if (step === undefined) continue;
|
||||||
|
|
||||||
|
// Estimate size: meta + content
|
||||||
|
const metaSize = JSON.stringify({
|
||||||
|
role: step.role,
|
||||||
|
output: step.output,
|
||||||
|
agent: step.agent,
|
||||||
|
}).length;
|
||||||
|
const contentSize = step.content?.length ?? 0;
|
||||||
|
const stepSize = metaSize + contentSize;
|
||||||
|
|
||||||
|
if (totalChars + stepSize > quota && selected.length > 0) {
|
||||||
|
// Stop adding steps but keep at least 1
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.unshift(step); // Keep chronological order
|
||||||
|
totalChars += stepSize;
|
||||||
|
|
||||||
|
if (totalChars >= quota) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildContinuationPromptOptions = {
|
||||||
|
includeContent?: boolean;
|
||||||
|
quota?: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a continuation prompt for a role re-entry.
|
* Build a continuation prompt for a role re-entry.
|
||||||
*
|
*
|
||||||
@@ -28,7 +76,11 @@ export function buildContinuationPrompt(
|
|||||||
steps: StepContext[],
|
steps: StepContext[],
|
||||||
role: string,
|
role: string,
|
||||||
edgePrompt: string,
|
edgePrompt: string,
|
||||||
|
options?: BuildContinuationPromptOptions,
|
||||||
): string {
|
): string {
|
||||||
|
const includeContent = options?.includeContent ?? false;
|
||||||
|
const quota = options?.quota ?? Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
const lastIndex = findLastRoleIndex(steps, role);
|
const lastIndex = findLastRoleIndex(steps, role);
|
||||||
const sinceSteps = lastIndex >= 0 ? steps.slice(lastIndex + 1) : steps;
|
const sinceSteps = lastIndex >= 0 ? steps.slice(lastIndex + 1) : steps;
|
||||||
|
|
||||||
@@ -37,13 +89,25 @@ export function buildContinuationPrompt(
|
|||||||
if (sinceSteps.length > 0) {
|
if (sinceSteps.length > 0) {
|
||||||
parts.push("## What Happened Since Your Last Turn");
|
parts.push("## What Happened Since Your Last Turn");
|
||||||
const baseStepNumber = lastIndex >= 0 ? lastIndex + 2 : 1;
|
const baseStepNumber = lastIndex >= 0 ? lastIndex + 2 : 1;
|
||||||
for (let i = 0; i < sinceSteps.length; i++) {
|
|
||||||
const step = sinceSteps[i];
|
// Select steps within quota (newest-first if includeContent = true)
|
||||||
|
const selectedSteps = includeContent ? selectStepsWithinQuota(sinceSteps, quota) : sinceSteps;
|
||||||
|
|
||||||
|
const skippedCount = sinceSteps.length - selectedSteps.length;
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push(
|
||||||
|
`_Showing ${selectedSteps.length} of ${sinceSteps.length} steps (${skippedCount} omitted due to quota)_`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < selectedSteps.length; i++) {
|
||||||
|
const step = selectedSteps[i];
|
||||||
if (step === undefined) {
|
if (step === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
parts.push("");
|
parts.push("");
|
||||||
parts.push(formatStep(step, baseStepNumber + i));
|
parts.push(formatStep(step, baseStepNumber + i, includeContent));
|
||||||
}
|
}
|
||||||
parts.push("");
|
parts.push("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,38 @@ function expandOutput(store: Store, outputRef: CasRef): unknown {
|
|||||||
return node.payload;
|
return node.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractStepContent(store: Store, detailRef: CasRef): string | null {
|
||||||
|
const detailNode = store.get(detailRef);
|
||||||
|
if (detailNode === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const detail = detailNode.payload as Record<string, unknown>;
|
||||||
|
const turns = detail.turns;
|
||||||
|
if (!Array.isArray(turns) || turns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Find last assistant content (same logic as extractLastAssistantContent in cli-workflow)
|
||||||
|
for (let i = turns.length - 1; i >= 0; i--) {
|
||||||
|
const turnRef = turns[i];
|
||||||
|
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 (
|
||||||
|
turn.role === "assistant" &&
|
||||||
|
typeof turn.content === "string" &&
|
||||||
|
turn.content.trim() !== ""
|
||||||
|
) {
|
||||||
|
return turn.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildHistory(
|
async function buildHistory(
|
||||||
store: Store,
|
store: Store,
|
||||||
stepsNewestFirst: StepNodePayload[],
|
stepsNewestFirst: StepNodePayload[],
|
||||||
@@ -89,12 +121,14 @@ async function buildHistory(
|
|||||||
const chronological = [...stepsNewestFirst].reverse();
|
const chronological = [...stepsNewestFirst].reverse();
|
||||||
const history: StepContext[] = [];
|
const history: StepContext[] = [];
|
||||||
for (const step of chronological) {
|
for (const step of chronological) {
|
||||||
|
const content = extractStepContent(store, step.detail);
|
||||||
history.push({
|
history.push({
|
||||||
role: step.role,
|
role: step.role,
|
||||||
output: expandOutput(store, step.output),
|
output: expandOutput(store, step.output),
|
||||||
detail: step.detail,
|
detail: step.detail,
|
||||||
agent: step.agent,
|
agent: step.agent,
|
||||||
edgePrompt: step.edgePrompt ?? "",
|
edgePrompt: step.edgePrompt ?? "",
|
||||||
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return history;
|
return history;
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ type RoleNodeData = {
|
|||||||
|
|
||||||
**边类型**:
|
**边类型**:
|
||||||
- `default`(GradientEdge)→ 渐变色边(绿→蓝),节点仅有一条出边时使用
|
- `default`(GradientEdge)→ 渐变色边(绿→蓝),节点仅有一条出边时使用
|
||||||
- `conditional`(ConditionalEdge)→ 带条件标签的渐变色边,节点有多条出边时使用
|
- `status`(StatusEdge)→ 带 status 标签的渐变色边,节点有多条出边时使用
|
||||||
|
|
||||||
**边渲染特性**:
|
**边渲染特性**:
|
||||||
- 渐变色:SVG linearGradient,从 source 端绿色(#10b981)到 target 端蓝色(#3b82f6)
|
- 渐变色:SVG linearGradient,从 source 端绿色(#10b981)到 target 端蓝色(#3b82f6)
|
||||||
@@ -234,7 +234,7 @@ Model 提供事务机制:
|
|||||||
```
|
```
|
||||||
ReactFlow
|
ReactFlow
|
||||||
├─ nodeTypes: { start: NodeStart, end: NodeEnd, role: NodeRole }
|
├─ nodeTypes: { start: NodeStart, end: NodeEnd, role: NodeRole }
|
||||||
└─ edgeTypes: { default: GradientEdge, conditional: ConditionalEdge }
|
└─ edgeTypes: { default: GradientEdge, status: StatusEdge }
|
||||||
```
|
```
|
||||||
|
|
||||||
`NodeRole` 显示角色名(data.name),使用 teal 色系图标和标签。Handle 分蓝色(in)和绿色(out)两种颜色。
|
`NodeRole` 显示角色名(data.name),使用 teal 色系图标和标签。Handle 分蓝色(in)和绿色(out)两种颜色。
|
||||||
@@ -324,12 +324,11 @@ type WorkflowPayload = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
roles: Record<string, RoleDefinition>; // 角色定义(4 段式:identity/prepare/execute/report)
|
roles: Record<string, RoleDefinition>; // 角色定义(4 段式:identity/prepare/execute/report)
|
||||||
conditions: Record<string, ConditionDefinition>; // JSONata 条件表达式
|
graph: Record<string, Record<string, Target>>; // status-based 路由图
|
||||||
graph: Record<string, Transition[]>; // 角色间的转移图
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Transition`。外部(CLI/server)负责 `WorkflowPayload` ↔ `WorkFlowSteps` 的转换。
|
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Target`。外部(CLI/server)负责 `WorkflowPayload` ↔ `WorkFlowSteps` 的转换。
|
||||||
|
|
||||||
## 11. 当前状态与待完善项
|
## 11. 当前状态与待完善项
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function createApi() {
|
|||||||
transitions: t.Array(
|
transitions: t.Array(
|
||||||
t.Object({
|
t.Object({
|
||||||
target: t.String(),
|
target: t.String(),
|
||||||
condition: t.Union([t.String(), t.Null()]),
|
status: t.String(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
import type { RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
import YAML from "yaml";
|
import YAML from "yaml";
|
||||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||||
|
|
||||||
@@ -11,17 +11,12 @@ async function ensureDir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
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 = [];
|
const steps: WorkFlowSteps = [];
|
||||||
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
||||||
const graphTransitions = payload.graph[roleName] ?? [];
|
const statusMap = payload.graph[roleName] ?? {};
|
||||||
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
|
const transitions: WorkFlowTransition[] = Object.entries(statusMap).map(([status, target]) => ({
|
||||||
target: t.role === "$END" ? "END" : t.role,
|
target: target.role === "$END" ? "END" : target.role,
|
||||||
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
|
status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
steps.push({
|
steps.push({
|
||||||
@@ -42,11 +37,7 @@ function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
|||||||
|
|
||||||
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
||||||
const roles: Record<string, RoleDefinition> = {};
|
const roles: Record<string, RoleDefinition> = {};
|
||||||
const conditions: WorkflowPayload["conditions"] = {};
|
const graph: Record<string, Record<string, Target>> = {};
|
||||||
const graph: Record<string, Transition[]> = {};
|
|
||||||
|
|
||||||
const expressionToName = new Map<string, string>();
|
|
||||||
let condIdx = 0;
|
|
||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const r = step.role;
|
const r = step.role;
|
||||||
@@ -59,43 +50,28 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
|||||||
frontmatter: "",
|
frontmatter: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const transitions: Transition[] = step.transitions.map((t) => {
|
const statusMap: Record<string, Target> = {};
|
||||||
let condName: string | null = null;
|
for (const t of step.transitions) {
|
||||||
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;
|
const targetRole = t.target === "END" ? "$END" : t.target;
|
||||||
return {
|
statusMap[t.status] = {
|
||||||
role: targetRole,
|
role: targetRole,
|
||||||
condition: condName,
|
|
||||||
prompt: `Transition to ${targetRole}.`,
|
prompt: `Transition to ${targetRole}.`,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
graph[r.name] = statusMap;
|
||||||
graph[r.name] = transitions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (steps.length > 0) {
|
if (steps.length > 0) {
|
||||||
const firstRole = steps[0].role.name;
|
const firstRole = steps[0].role.name;
|
||||||
graph.$START = [
|
graph.$START = {
|
||||||
{
|
_: {
|
||||||
role: firstRole,
|
role: firstRole,
|
||||||
condition: null,
|
|
||||||
prompt: `Begin workflow at role ${firstRole}.`,
|
prompt: `Begin workflow at role ${firstRole}.`,
|
||||||
},
|
},
|
||||||
];
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name, description, roles, conditions, graph };
|
return { name, description, roles, graph };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||||
@@ -125,7 +101,6 @@ export async function createWorkflow(name: string, description: string): Promise
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
roles: {},
|
roles: {},
|
||||||
conditions: {},
|
|
||||||
graph: {},
|
graph: {},
|
||||||
};
|
};
|
||||||
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type WorkFlowRole = {
|
|||||||
|
|
||||||
export type WorkFlowTransition = {
|
export type WorkFlowTransition = {
|
||||||
target: string;
|
target: string;
|
||||||
condition: string | null;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkFlowStep = {
|
export type WorkFlowStep = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ConditionalEdge, GradientEdge } from "./conditional";
|
import { GradientEdge, StatusEdge } from "./status";
|
||||||
|
|
||||||
export const edgeTypes = {
|
export const edgeTypes = {
|
||||||
conditional: ConditionalEdge,
|
status: StatusEdge,
|
||||||
default: GradientEdge,
|
default: GradientEdge,
|
||||||
};
|
};
|
||||||
|
|||||||
+24
-52
@@ -6,10 +6,10 @@ import {
|
|||||||
useReactFlow,
|
useReactFlow,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "../../lib/utils.ts";
|
import { cn } from "../../lib/utils.ts";
|
||||||
import { useModel } from "../context.tsx";
|
import { useModel } from "../context.tsx";
|
||||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
import type { StatusEdge as StatusEdgeType } from "../type.ts";
|
||||||
|
|
||||||
const SOURCE_COLOR = "#10b981";
|
const SOURCE_COLOR = "#10b981";
|
||||||
const TARGET_COLOR = "#3b82f6";
|
const TARGET_COLOR = "#3b82f6";
|
||||||
@@ -23,7 +23,7 @@ function GradientPath({
|
|||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
hasCondition,
|
hasStatus,
|
||||||
selected,
|
selected,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,11 +32,11 @@ function GradientPath({
|
|||||||
sourceY: number;
|
sourceY: number;
|
||||||
targetX: number;
|
targetX: number;
|
||||||
targetY: number;
|
targetY: number;
|
||||||
hasCondition: boolean | null;
|
hasStatus: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}) {
|
}) {
|
||||||
const gradientId = `gradient-${id}`;
|
const gradientId = `gradient-${id}`;
|
||||||
const showLack = hasCondition === false;
|
const showLack = !hasStatus;
|
||||||
const strokeStyle = selected
|
const strokeStyle = selected
|
||||||
? { stroke: "#f59e0b", strokeWidth: 2 }
|
? { stroke: "#f59e0b", strokeWidth: 2 }
|
||||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||||
@@ -68,35 +68,20 @@ function GradientPath({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
|
type StatusLabelProps = {
|
||||||
return (
|
status: string | undefined;
|
||||||
<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;
|
labelX: number;
|
||||||
labelY: number;
|
labelY: number;
|
||||||
onSave: (value: string) => void;
|
onSave: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
|
function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): ReactNode {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
function handleBadgeClick() {
|
function handleBadgeClick() {
|
||||||
setInputValue(condition || "");
|
setInputValue(status || "");
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +112,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const displayStatus = status?.trim() || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -142,11 +129,13 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block px-1 bg-white rounded text-[10px]",
|
"inline-block px-1 bg-white rounded text-[10px]",
|
||||||
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
|
displayStatus
|
||||||
|
? "border border-gray-300 text-black"
|
||||||
|
: "border border-dashed text-red-500",
|
||||||
)}
|
)}
|
||||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
style={displayStatus ? undefined : { borderColor: LACK_COLOR }}
|
||||||
>
|
>
|
||||||
if
|
{displayStatus ?? "status"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -155,7 +144,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
|
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}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -174,14 +163,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
export function StatusEdge({
|
||||||
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
|
|
||||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConditionalEdge({
|
|
||||||
id,
|
id,
|
||||||
source,
|
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
@@ -190,7 +173,7 @@ export function ConditionalEdge({
|
|||||||
targetPosition,
|
targetPosition,
|
||||||
selected,
|
selected,
|
||||||
data,
|
data,
|
||||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
}: EdgeProps<StatusEdgeType>): ReactNode {
|
||||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
@@ -203,13 +186,11 @@ export function ConditionalEdge({
|
|||||||
const flow = useReactFlow();
|
const flow = useReactFlow();
|
||||||
const model = useModel();
|
const model = useModel();
|
||||||
|
|
||||||
const allEdges = flow.getEdges();
|
const status = data?.status;
|
||||||
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
|
|
||||||
|
|
||||||
const condition = data?.condition;
|
|
||||||
function handleSave(value: string) {
|
function handleSave(value: string) {
|
||||||
model.startTransaction();
|
model.startTransaction();
|
||||||
flow.updateEdgeData(id, { condition: value });
|
flow.updateEdgeData(id, { status: value });
|
||||||
requestAnimationFrame(model.endTransaction);
|
requestAnimationFrame(model.endTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,20 +203,11 @@ export function ConditionalEdge({
|
|||||||
sourceY={sourceY}
|
sourceY={sourceY}
|
||||||
targetX={targetX}
|
targetX={targetX}
|
||||||
targetY={targetY}
|
targetY={targetY}
|
||||||
hasCondition={isElse ? null : !!condition}
|
hasStatus={!!status?.trim()}
|
||||||
selected={!!selected}
|
selected={!!selected}
|
||||||
/>
|
/>
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
{isElse ? (
|
<StatusLabel status={status} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||||
<ElseBadge labelX={labelX} labelY={labelY} />
|
|
||||||
) : (
|
|
||||||
<ConditionLabel
|
|
||||||
condition={condition}
|
|
||||||
labelX={labelX}
|
|
||||||
labelY={labelY}
|
|
||||||
onSave={handleSave}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</EdgeLabelRenderer>
|
</EdgeLabelRenderer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -269,7 +241,7 @@ export function GradientEdge({
|
|||||||
sourceY={sourceY}
|
sourceY={sourceY}
|
||||||
targetX={targetX}
|
targetX={targetX}
|
||||||
targetY={targetY}
|
targetY={targetY}
|
||||||
hasCondition={null}
|
hasStatus={true}
|
||||||
selected={!!selected}
|
selected={!!selected}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -65,12 +65,12 @@ export const edgesModel = define.model("edges", makeEdges, (set, get, model) =>
|
|||||||
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
||||||
|
|
||||||
if (existingFromSource.length > 0) {
|
if (existingFromSource.length > 0) {
|
||||||
edge.type = "conditional";
|
edge.type = "status";
|
||||||
edge.data = { condition: "" };
|
edge.data = { status: "" };
|
||||||
|
|
||||||
const promoted = currentEdges.map((e) => {
|
const promoted = currentEdges.map((e) => {
|
||||||
if (e.source === normalized.source && e.type !== "conditional") {
|
if (e.source === normalized.source && e.type !== "status") {
|
||||||
return { ...e, type: "conditional" as const, data: { condition: "" } };
|
return { ...e, type: "status" as const, data: { status: "_" } };
|
||||||
}
|
}
|
||||||
return e;
|
return e;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,21 +34,8 @@ export const handlers = define.memoize((use, model) => {
|
|||||||
return node.type === "start" || node.type === "end";
|
return node.type === "start" || node.type === "end";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFirstConditionalSibling(
|
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes }) => {
|
||||||
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 (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();
|
model.startTransaction();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -56,16 +43,14 @@ export const handlers = define.memoize((use, model) => {
|
|||||||
if (deletedEdges.length > 0) {
|
if (deletedEdges.length > 0) {
|
||||||
const currentEdges = use(edgesModel)[0];
|
const currentEdges = use(edgesModel)[0];
|
||||||
const sourcesToCheck = new Set(
|
const sourcesToCheck = new Set(
|
||||||
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
|
deletedEdges.filter((e) => e.type === "status").map((e) => e.source),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sourcesToCheck.size > 0) {
|
if (sourcesToCheck.size > 0) {
|
||||||
let needsDowngrade = false;
|
let needsDowngrade = false;
|
||||||
const updatedEdges = currentEdges.map((e) => {
|
const updatedEdges = currentEdges.map((e) => {
|
||||||
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
|
if (!sourcesToCheck.has(e.source) || e.type !== "status") return e;
|
||||||
const siblings = currentEdges.filter(
|
const siblings = currentEdges.filter((s) => s.source === e.source && s.type === "status");
|
||||||
(s) => s.source === e.source && s.type === "conditional",
|
|
||||||
);
|
|
||||||
if (siblings.length === 1) {
|
if (siblings.length === 1) {
|
||||||
needsDowngrade = true;
|
needsDowngrade = true;
|
||||||
const { data: _, ...rest } = e;
|
const { data: _, ...rest } = e;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("transIn", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("4.3 Single step with END transition → edge to end node exists", () => {
|
it("4.3 Single step with END transition → edge to end node exists", () => {
|
||||||
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
|
const steps = [makeStep("A", [{ status: "_", target: "END" }])];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
const endEdge = edges.find((e) => e.target === "end");
|
const endEdge = edges.find((e) => e.target === "end");
|
||||||
expect(endEdge).toBeDefined();
|
expect(endEdge).toBeDefined();
|
||||||
@@ -44,8 +44,8 @@ describe("transIn", () => {
|
|||||||
|
|
||||||
it("4.4 Two steps with default transitions chain", () => {
|
it("4.4 Two steps with default transitions chain", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [{ condition: null, target: "B" }]),
|
makeStep("A", [{ status: "_", target: "B" }]),
|
||||||
makeStep("B", [{ condition: null, target: "END" }]),
|
makeStep("B", [{ status: "_", target: "END" }]),
|
||||||
];
|
];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
// Should have start→A, A→B, B→end
|
// Should have start→A, A→B, B→end
|
||||||
@@ -53,15 +53,15 @@ describe("transIn", () => {
|
|||||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
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.source === nodeAId && e.target !== "end")).toBeDefined();
|
||||||
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
||||||
// No conditional edges
|
// No status edges for single default transitions
|
||||||
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
|
expect(edges.every((e) => e.type !== "status")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("4.5 Step with multiple transitions → conditional edges", () => {
|
it("4.5 Step with multiple transitions → status edges", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [
|
makeStep("A", [
|
||||||
{ condition: null, target: "B" },
|
{ status: "_", target: "B" },
|
||||||
{ condition: "x>0", target: "C" },
|
{ status: "approved", target: "C" },
|
||||||
]),
|
]),
|
||||||
makeStep("B", []),
|
makeStep("B", []),
|
||||||
makeStep("C", []),
|
makeStep("C", []),
|
||||||
@@ -69,23 +69,35 @@ describe("transIn", () => {
|
|||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||||
const outEdges = edges.filter((e) => e.source === nodeAId);
|
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||||
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
|
expect(outEdges.every((e) => e.type === "status")).toBe(true);
|
||||||
// else-branch has empty condition
|
});
|
||||||
const elseEdge = outEdges.find(
|
|
||||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
|
it("4.5b Multiple transitions include expected status values", () => {
|
||||||
|
const steps = [
|
||||||
|
makeStep("A", [
|
||||||
|
{ status: "_", target: "B" },
|
||||||
|
{ status: "approved", target: "C" },
|
||||||
|
]),
|
||||||
|
makeStep("B", []),
|
||||||
|
makeStep("C", []),
|
||||||
|
];
|
||||||
|
const { edges } = transIn(steps);
|
||||||
|
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||||
|
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||||
|
const defaultEdge = outEdges.find(
|
||||||
|
(e) => (e as { data?: { status?: string } }).data?.status === "_",
|
||||||
);
|
);
|
||||||
expect(elseEdge).toBeDefined();
|
expect(defaultEdge).toBeDefined();
|
||||||
// if-branch has condition
|
const approvedEdge = outEdges.find(
|
||||||
const ifEdge = outEdges.find(
|
(e) => (e as { data?: { status?: string } }).data?.status === "approved",
|
||||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
|
|
||||||
);
|
);
|
||||||
expect(ifEdge).toBeDefined();
|
expect(approvedEdge).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [{ condition: null, target: "END" }]),
|
makeStep("A", [{ status: "_", target: "END" }]),
|
||||||
makeStep("B", [{ condition: null, target: "END" }]),
|
makeStep("B", [{ status: "_", target: "END" }]),
|
||||||
];
|
];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
// start→A and start→B; end has 2 incoming edges
|
// start→A and start→B; end has 2 incoming edges
|
||||||
@@ -95,8 +107,8 @@ describe("transIn", () => {
|
|||||||
|
|
||||||
it("4.7 Same role name maps to same node id across steps", () => {
|
it("4.7 Same role name maps to same node id across steps", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [{ condition: null, target: "B" }]),
|
makeStep("A", [{ status: "_", target: "B" }]),
|
||||||
makeStep("B", [{ condition: null, target: "A" }]),
|
makeStep("B", [{ status: "_", target: "A" }]),
|
||||||
];
|
];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
const aId = edges.find((e) => e.source === "start")?.target;
|
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;
|
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
|
||||||
}
|
}
|
||||||
|
|
||||||
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
|
function statusEdge(source: string, target: string, status: string): AnyWorkEdge {
|
||||||
return {
|
return {
|
||||||
id: `${source}-${target}-cond`,
|
id: `${source}-${target}-status`,
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
type: "conditional" as const,
|
type: "status" as const,
|
||||||
data: { condition },
|
data: { status },
|
||||||
animated: true,
|
animated: true,
|
||||||
} as AnyWorkEdge;
|
} as AnyWorkEdge;
|
||||||
}
|
}
|
||||||
@@ -76,36 +76,36 @@ describe("validateRoleNodes (via validate)", () => {
|
|||||||
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("5.3 Empty condition on non-first conditional edge → error", () => {
|
it("5.3 Empty status on status edge → error", () => {
|
||||||
const n1 = roleNode("n1");
|
const n1 = roleNode("n1");
|
||||||
const n2 = roleNode("n2");
|
const n2 = roleNode("n2");
|
||||||
const n3 = roleNode("n3");
|
const n3 = roleNode("n3");
|
||||||
const nodes = baseNodes(n1, n2, n3);
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
const edges = [
|
const edges = [
|
||||||
defaultEdge("start", "n1"),
|
defaultEdge("start", "n1"),
|
||||||
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
|
statusEdge("n1", "n2", "_"),
|
||||||
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
|
statusEdge("n1", "n3", ""), // empty status → error
|
||||||
defaultEdge("n2", "end"),
|
defaultEdge("n2", "end"),
|
||||||
defaultEdge("n3", "end"),
|
defaultEdge("n3", "end"),
|
||||||
];
|
];
|
||||||
const result = validate(nodes, edges);
|
const result = validate(nodes, edges);
|
||||||
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
|
expect(result.errors.some((e) => e.message.includes("状态值不能为空"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
|
it("5.4 Mix of status and non-status outgoing → error", () => {
|
||||||
const n1 = roleNode("n1");
|
const n1 = roleNode("n1");
|
||||||
const n2 = roleNode("n2");
|
const n2 = roleNode("n2");
|
||||||
const n3 = roleNode("n3");
|
const n3 = roleNode("n3");
|
||||||
const nodes = baseNodes(n1, n2, n3);
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
const edges = [
|
const edges = [
|
||||||
defaultEdge("start", "n1"),
|
defaultEdge("start", "n1"),
|
||||||
conditionalEdge("n1", "n2", "x>0"),
|
statusEdge("n1", "n2", "approved"),
|
||||||
defaultEdge("n1", "n3"), // mix → error
|
defaultEdge("n1", "n3"), // mix → error
|
||||||
defaultEdge("n2", "end"),
|
defaultEdge("n2", "end"),
|
||||||
defaultEdge("n3", "end"),
|
defaultEdge("n3", "end"),
|
||||||
];
|
];
|
||||||
const result = validate(nodes, edges);
|
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", () => {
|
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);
|
expect(roleErrors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
|
it("5.6 Valid role node (1 in, 2 status out with statuses) → no errors", () => {
|
||||||
const n1 = roleNode("n1");
|
const n1 = roleNode("n1");
|
||||||
const n2 = roleNode("n2");
|
const n2 = roleNode("n2");
|
||||||
const n3 = roleNode("n3");
|
const n3 = roleNode("n3");
|
||||||
const nodes = baseNodes(n1, n2, n3);
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
const edges = [
|
const edges = [
|
||||||
defaultEdge("start", "n1"),
|
defaultEdge("start", "n1"),
|
||||||
conditionalEdge("n1", "n2", ""), // else-branch
|
statusEdge("n1", "n2", "_"),
|
||||||
conditionalEdge("n1", "n3", "x>0"), // if-branch
|
statusEdge("n1", "n3", "approved"),
|
||||||
defaultEdge("n2", "end"),
|
defaultEdge("n2", "end"),
|
||||||
defaultEdge("n3", "end"),
|
defaultEdge("n3", "end"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||||
import { uuid } from "../utils";
|
import { uuid } from "../utils";
|
||||||
import type { WorkFlowStep } from "./type";
|
import type { WorkFlowStep } from "./type";
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ type Result = {
|
|||||||
|
|
||||||
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
||||||
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
||||||
|
const DEFAULT_STATUS = "_";
|
||||||
|
|
||||||
function assignHandles(
|
function assignHandles(
|
||||||
indices: number[],
|
indices: number[],
|
||||||
@@ -50,8 +51,8 @@ function buildNodeMap(
|
|||||||
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
||||||
if (step.transitions.length <= 1) return step.transitions;
|
if (step.transitions.length <= 1) return step.transitions;
|
||||||
return [...step.transitions].sort((a, b) => {
|
return [...step.transitions].sort((a, b) => {
|
||||||
if (a.condition === null && b.condition !== null) return -1;
|
if (a.status === DEFAULT_STATUS && b.status !== DEFAULT_STATUS) return -1;
|
||||||
if (a.condition !== null && b.condition === null) return 1;
|
if (a.status !== DEFAULT_STATUS && b.status === DEFAULT_STATUS) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -60,32 +61,32 @@ function buildStepEdges(
|
|||||||
sourceId: string,
|
sourceId: string,
|
||||||
step: WorkFlowStep,
|
step: WorkFlowStep,
|
||||||
nameToId: Map<string, string>,
|
nameToId: Map<string, string>,
|
||||||
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
|
): { primaryEdges: AnyWorkEdge[]; statusEdges: AnyWorkEdge[] } {
|
||||||
const hasMultiple = step.transitions.length > 1;
|
const hasMultiple = step.transitions.length > 1;
|
||||||
const sorted = sortTransitions(step);
|
const sorted = sortTransitions(step);
|
||||||
const elseEdges: AnyWorkEdge[] = [];
|
const primaryEdges: AnyWorkEdge[] = [];
|
||||||
const ifEdges: AnyWorkEdge[] = [];
|
const statusEdges: AnyWorkEdge[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
const t = sorted[i];
|
const t = sorted[i];
|
||||||
const targetId = nameToId.get(t.target);
|
const targetId = nameToId.get(t.target);
|
||||||
if (!targetId) continue;
|
if (!targetId) continue;
|
||||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||||
if (hasMultiple || t.condition !== null) {
|
if (hasMultiple || t.status !== DEFAULT_STATUS) {
|
||||||
const edge: ConditionalEdge = {
|
const edge: StatusEdge = {
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
target: targetId,
|
target: targetId,
|
||||||
sourceHandle: "output",
|
sourceHandle: "output",
|
||||||
targetHandle: "input",
|
targetHandle: "input",
|
||||||
type: "conditional",
|
type: "status",
|
||||||
data: { condition: t.condition ?? "" },
|
data: { status: t.status },
|
||||||
animated: true,
|
animated: true,
|
||||||
};
|
};
|
||||||
if (hasMultiple && i === 0) elseEdges.push(edge);
|
if (hasMultiple && t.status === DEFAULT_STATUS) primaryEdges.push(edge);
|
||||||
else ifEdges.push(edge);
|
else statusEdges.push(edge);
|
||||||
} else {
|
} else {
|
||||||
elseEdges.push({
|
primaryEdges.push({
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
target: targetId,
|
target: targetId,
|
||||||
@@ -95,23 +96,23 @@ function buildStepEdges(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { elseEdges, ifEdges };
|
return { primaryEdges, statusEdges };
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushStepEdges(
|
function pushStepEdges(
|
||||||
edges: AnyWorkEdge[],
|
edges: AnyWorkEdge[],
|
||||||
elseEdges: AnyWorkEdge[],
|
primaryEdges: AnyWorkEdge[],
|
||||||
ifEdges: AnyWorkEdge[],
|
statusEdges: AnyWorkEdge[],
|
||||||
idToOrder: Map<string, number>,
|
idToOrder: Map<string, number>,
|
||||||
): void {
|
): void {
|
||||||
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
|
for (const e of primaryEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||||
if (ifEdges.length > 0) {
|
if (statusEdges.length > 0) {
|
||||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
const statusHandles = ["output-top", "output-bottom"] as const;
|
||||||
const sorted = [...ifEdges].sort(
|
const sorted = [...statusEdges].sort(
|
||||||
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
||||||
);
|
);
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
edges.push({ ...sorted[i], sourceHandle: statusHandles[i % statusHandles.length] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,8 +165,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
|||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||||
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
const { primaryEdges, statusEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||||
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
pushStepEdges(edges, primaryEdges, statusEdges, idToOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
assignTargetHandles(edges, idToOrder);
|
assignTargetHandles(edges, idToOrder);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
|
import type { AnyWorkEdge, AnyWorkNode, StatusEdge, WorkNode } from "../type";
|
||||||
import type { WorkFlowStep, WorkFlowTransition } from "./type";
|
import type { WorkFlowStep, WorkFlowTransition } from "./type";
|
||||||
|
|
||||||
|
const DEFAULT_STATUS = "_";
|
||||||
|
|
||||||
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
||||||
const nodeMap = new Map<string, AnyWorkNode>();
|
const nodeMap = new Map<string, AnyWorkNode>();
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -43,7 +45,7 @@ function traverse(
|
|||||||
const roleNode = node as WorkNode<"role">;
|
const roleNode = node as WorkNode<"role">;
|
||||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
||||||
|
|
||||||
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
|
const transitions: WorkFlowTransition[] = outEdges.map((edge) => {
|
||||||
const targetNode = nodeMap.get(edge.target);
|
const targetNode = nodeMap.get(edge.target);
|
||||||
const target =
|
const target =
|
||||||
edge.target === "end"
|
edge.target === "end"
|
||||||
@@ -52,13 +54,12 @@ function traverse(
|
|||||||
? (targetNode as WorkNode<"role">).data.name
|
? (targetNode as WorkNode<"role">).data.name
|
||||||
: edge.target;
|
: edge.target;
|
||||||
|
|
||||||
let condition: string | null = null;
|
const status =
|
||||||
if (edge.type === "conditional") {
|
edge.type === "status"
|
||||||
const isElse = outEdges.length >= 2 && index === 0;
|
? ((edge as StatusEdge).data?.status ?? DEFAULT_STATUS)
|
||||||
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
|
: DEFAULT_STATUS;
|
||||||
}
|
|
||||||
|
|
||||||
return { target, condition };
|
return { target, status };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||||
|
|
||||||
export type ValidationError = {
|
export type ValidationError = {
|
||||||
nodeId: string | null;
|
nodeId: string | null;
|
||||||
@@ -91,10 +91,10 @@ function validateEndNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
|
function hasEmptyStatusOnEdge(statusEdges: AnyWorkEdge[]): boolean {
|
||||||
return conditionalEdges.slice(1).some((edge) => {
|
return statusEdges.some((edge) => {
|
||||||
const cond = (edge as ConditionalEdge).data?.condition?.trim();
|
const status = (edge as StatusEdge).data?.status?.trim();
|
||||||
return !cond;
|
return !status;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,11 +113,11 @@ function validateRoleNodeEdges(
|
|||||||
}
|
}
|
||||||
if (outEdges.length <= 1) return;
|
if (outEdges.length <= 1) return;
|
||||||
|
|
||||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
const statusEdges = outEdges.filter((e) => e.type === "status");
|
||||||
if (conditionalEdges.length !== outEdges.length) {
|
if (statusEdges.length !== outEdges.length) {
|
||||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带状态" });
|
||||||
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
|
} else if (hasEmptyStatusOnEdge(statusEdges)) {
|
||||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
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 WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
|
||||||
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
||||||
|
|
||||||
export type ConditionalEdgeData = AnyKeyBase & {
|
export type StatusEdgeData = AnyKeyBase & {
|
||||||
condition: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
|
export type StatusEdge = Edge<StatusEdgeData, "status">;
|
||||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
export type AnyWorkEdge = StatusEdge | Edge;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
|||||||
execute: "制定详细的实施计划和步骤分解",
|
execute: "制定详细的实施计划和步骤分解",
|
||||||
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
||||||
},
|
},
|
||||||
transitions: [{ target: "developer", condition: null }],
|
transitions: [{ target: "developer", status: "_" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: {
|
role: {
|
||||||
@@ -22,7 +22,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
|||||||
execute: "编写高质量的代码实现",
|
execute: "编写高质量的代码实现",
|
||||||
report: "输出变更文件列表和实现摘要",
|
report: "输出变更文件列表和实现摘要",
|
||||||
},
|
},
|
||||||
transitions: [{ target: "reviewer", condition: null }],
|
transitions: [{ target: "reviewer", status: "_" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: {
|
role: {
|
||||||
@@ -34,8 +34,8 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
|||||||
report: "输出审查结果,包含 approved 状态和评审意见",
|
report: "输出审查结果,包含 approved 状态和评审意见",
|
||||||
},
|
},
|
||||||
transitions: [
|
transitions: [
|
||||||
{ target: "END", condition: null },
|
{ target: "END", status: "approved" },
|
||||||
{ target: "developer", condition: "steps[-1].output.approved = false" },
|
{ target: "developer", status: "rejected" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# @uncaged/workflow-moderator
|
# @uncaged/workflow-moderator
|
||||||
|
|
||||||
JSONata-based graph evaluator — determines the next role or `$END` with zero LLM cost.
|
Status-based graph evaluator — determines the next role or `$END` with zero LLM cost.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The moderator (Layer 1) walks the workflow graph from the current role. For each outgoing transition it evaluates an optional JSONata condition against `ModeratorContext` (start prompt + prior step outputs). The first truthy transition wins; its target role and edge prompt are returned. When no transition matches, the workflow ends (`$END`).
|
The moderator (Layer 1) performs a status-based map lookup on the workflow graph. Given the last role and its output, it looks up `graph[lastRole][lastOutput.status]` to find the next `Target` (role + prompt template). The prompt is rendered via Mustache with `lastOutput` as the template context. For `$START`, the unit status `_` is used.
|
||||||
|
|
||||||
**Dependencies:** `@uncaged/workflow-protocol`, `jsonata`
|
**Dependencies:** `@uncaged/workflow-protocol`, `mustache`
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -20,12 +20,13 @@ bun add @uncaged/workflow-moderator
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function evaluate(
|
function evaluate(
|
||||||
workflow: WorkflowPayload,
|
graph: Record<string, Record<string, Target>>,
|
||||||
context: ModeratorContext,
|
lastRole: string,
|
||||||
): Promise<Result<EvaluateResult, Error>>
|
lastOutput: Record<string, unknown> & { status: string },
|
||||||
|
): Result<EvaluateResult, Error>
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the edge instruction for the agent.
|
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the rendered edge instruction for the agent.
|
||||||
|
|
||||||
### Types
|
### Types
|
||||||
|
|
||||||
@@ -42,9 +43,9 @@ The `Result<T, E>` type is local to this package (`{ ok: true; value: T } | { ok
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { evaluate } from "@uncaged/workflow-moderator";
|
import { evaluate } from "@uncaged/workflow-moderator";
|
||||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
import type { Target } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
const result = await evaluate(workflow, context);
|
const result = evaluate(graph, lastRole, lastOutput);
|
||||||
if (result.ok && result.value.role !== "$END") {
|
if (result.ok && result.value.role !== "$END") {
|
||||||
console.log(`Next role: ${result.value.role}, prompt: ${result.value.prompt}`);
|
console.log(`Next role: ${result.value.role}, prompt: ${result.value.prompt}`);
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,6 @@ if (result.ok && result.value.role !== "$END") {
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── index.ts Public exports
|
├── index.ts Public exports
|
||||||
├── evaluate.ts Graph walk + JSONata condition evaluation
|
├── evaluate.ts Status-based map lookup + Mustache prompt rendering
|
||||||
└── types.ts EvaluateResult, Result
|
└── types.ts EvaluateResult, Result
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,312 +1,122 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
import { evaluate } from "../src/evaluate.js";
|
import { evaluate } from "../src/evaluate.js";
|
||||||
|
|
||||||
const solveIssueWorkflow: WorkflowPayload = {
|
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||||
name: "solve-issue",
|
$START: {
|
||||||
description: "End-to-end issue resolution",
|
_: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||||
roles: {
|
|
||||||
planner: {
|
|
||||||
description: "Creates implementation plan",
|
|
||||||
goal: "You are a planning agent.",
|
|
||||||
capabilities: ["planning"],
|
|
||||||
procedure: "Create a step-by-step plan.",
|
|
||||||
output: "Output the plan and steps.",
|
|
||||||
frontmatter: "5GWKR8TN1V3JA",
|
|
||||||
},
|
|
||||||
developer: {
|
|
||||||
description: "Implements code changes",
|
|
||||||
goal: "You are a developer agent.",
|
|
||||||
capabilities: ["coding"],
|
|
||||||
procedure: "Implement the plan.",
|
|
||||||
output: "List files changed and summary.",
|
|
||||||
frontmatter: "8CNWT4KR6D1HV",
|
|
||||||
},
|
|
||||||
reviewer: {
|
|
||||||
description: "Reviews code changes",
|
|
||||||
goal: "You are a code reviewer.",
|
|
||||||
capabilities: ["code-review"],
|
|
||||||
procedure: "Review the implementation.",
|
|
||||||
output: "Approve or reject with comments.",
|
|
||||||
frontmatter: "1VPBG9SM5E7WK",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
conditions: {
|
planner: {
|
||||||
needsClarification: {
|
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
|
||||||
description: "Planner requests clarification from user",
|
|
||||||
expression: "$exists($last('planner').needsClarification)",
|
|
||||||
},
|
|
||||||
rejected: {
|
|
||||||
description: "Reviewer rejected the implementation",
|
|
||||||
expression: "$last('reviewer').approved = false",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
graph: {
|
developer: {
|
||||||
$START: [
|
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
|
||||||
{
|
},
|
||||||
role: "planner",
|
reviewer: {
|
||||||
condition: null,
|
approved: { role: "$END", prompt: "Done." },
|
||||||
prompt: "Start planning from the issue in the task.",
|
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
|
||||||
},
|
|
||||||
],
|
|
||||||
planner: [
|
|
||||||
{
|
|
||||||
role: "developer",
|
|
||||||
condition: "needsClarification",
|
|
||||||
prompt: "Clarification is needed; hand off to developer.",
|
|
||||||
},
|
|
||||||
{ role: "$END", condition: null, prompt: "Planning complete; end workflow." },
|
|
||||||
],
|
|
||||||
developer: [
|
|
||||||
{
|
|
||||||
role: "reviewer",
|
|
||||||
condition: null,
|
|
||||||
prompt: "Implementation done; send to reviewer.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
reviewer: [
|
|
||||||
{
|
|
||||||
role: "developer",
|
|
||||||
condition: "rejected",
|
|
||||||
prompt: "Reviewer rejected; return to developer.",
|
|
||||||
},
|
|
||||||
{ role: "$END", condition: null, prompt: "Review passed; end workflow." },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
|
||||||
return {
|
|
||||||
start: {
|
|
||||||
workflow: "4KNM2PXR3B1QW",
|
|
||||||
prompt: "Fix the login bug",
|
|
||||||
},
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("evaluate", () => {
|
describe("evaluate", () => {
|
||||||
test("$START → first role (fallback)", async () => {
|
test("$START → first role (unit status _)", () => {
|
||||||
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
const result = evaluate(solveIssueGraph, "$START", { status: "_" });
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("condition match (rejected → developer)", async () => {
|
test("status-based routing (reviewer rejected → developer)", () => {
|
||||||
const context = makeContext([
|
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||||
{
|
status: "rejected",
|
||||||
role: "reviewer",
|
comments: "missing tests",
|
||||||
output: { approved: false },
|
});
|
||||||
detail: "2MXBG6PN4A8JR",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "developer", prompt: "Reviewer rejected; return to developer." },
|
value: { role: "developer", prompt: "Fix: missing tests" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fallback when condition does not match → $END", async () => {
|
test("status-based routing (reviewer approved → $END)", () => {
|
||||||
const context = makeContext([
|
const result = evaluate(solveIssueGraph, "reviewer", { status: "approved" });
|
||||||
{
|
|
||||||
role: "reviewer",
|
|
||||||
output: { approved: true },
|
|
||||||
detail: "2MXBG6PN4A8JR",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "$END", prompt: "Review passed; end workflow." },
|
value: { role: "$END", prompt: "Done." },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing role in graph → error", async () => {
|
test("missing role in graph → error", () => {
|
||||||
const context = makeContext([
|
const result = evaluate(solveIssueGraph, "unknown-role", { status: "_" });
|
||||||
{
|
|
||||||
role: "unknown-role",
|
|
||||||
output: {},
|
|
||||||
detail: "2MXBG6PN4A8JR",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("output expansion in context works with JSONata", async () => {
|
test("missing status in graph → error", () => {
|
||||||
const context = makeContext([
|
const result = evaluate(solveIssueGraph, "reviewer", { status: "pending" });
|
||||||
{
|
expect(result.ok).toBe(false);
|
||||||
role: "planner",
|
if (!result.ok) {
|
||||||
output: { needsClarification: true },
|
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||||
detail: "7BQST3VW9F2MA",
|
}
|
||||||
agent: "uwf-hermes",
|
});
|
||||||
},
|
|
||||||
]);
|
test("mustache template rendering with simple fields", () => {
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
const result = evaluate(solveIssueGraph, "planner", {
|
||||||
|
status: "_",
|
||||||
|
plan: "Add auth middleware",
|
||||||
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "developer", prompt: "Clarification is needed; hand off to developer." },
|
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("$last returns most recent matching role's frontmatter", async () => {
|
test("mustache does not HTML-escape prompt content", () => {
|
||||||
const workflow: WorkflowPayload = {
|
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||||
...solveIssueWorkflow,
|
status: "rejected",
|
||||||
conditions: {
|
comments: 'use <T> & "Result<T, E>" types',
|
||||||
devFailed: {
|
});
|
||||||
description: "Developer failed",
|
|
||||||
expression: "$last('developer').status = 'failed'",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
graph: {
|
|
||||||
$START: [
|
|
||||||
{
|
|
||||||
role: "developer",
|
|
||||||
condition: null,
|
|
||||||
prompt: "Begin development.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
developer: [
|
|
||||||
{ role: "$END", condition: "devFailed", prompt: "Development failed; end." },
|
|
||||||
{
|
|
||||||
role: "reviewer",
|
|
||||||
condition: null,
|
|
||||||
prompt: "Development succeeded; review.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const context = makeContext([
|
|
||||||
{
|
|
||||||
role: "developer",
|
|
||||||
output: { status: "done" },
|
|
||||||
detail: "1VPBG9SM5E7WK",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "reviewer",
|
|
||||||
output: { approved: false },
|
|
||||||
detail: "2MXBG6PN4A8JR",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "developer",
|
|
||||||
output: { status: "failed" },
|
|
||||||
detail: "3QNTH7WK8D2PA",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await evaluate(workflow, context);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "$END", prompt: "Development failed; end." },
|
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("$first returns earliest matching role's frontmatter", async () => {
|
test("triple mustache also works for unescaped output", () => {
|
||||||
const workflow: WorkflowPayload = {
|
const graph: Record<string, Record<string, Target>> = {
|
||||||
...solveIssueWorkflow,
|
reviewer: {
|
||||||
conditions: {
|
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
|
||||||
firstPlanReady: {
|
|
||||||
description: "First planner run was ready",
|
|
||||||
expression: "$first('planner').status = 'ready'",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
graph: {
|
|
||||||
$START: [
|
|
||||||
{
|
|
||||||
role: "planner",
|
|
||||||
condition: null,
|
|
||||||
prompt: "Begin planning.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
planner: [
|
|
||||||
{ role: "$END", condition: "firstPlanReady", prompt: "First plan was ready; end." },
|
|
||||||
{
|
|
||||||
role: "developer",
|
|
||||||
condition: null,
|
|
||||||
prompt: "Plan not ready on first pass; implement.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const context = makeContext([
|
const result = evaluate(graph, "reviewer", {
|
||||||
{
|
status: "_",
|
||||||
role: "planner",
|
comments: "<script>alert(1)</script>",
|
||||||
output: { status: "ready", plan: "ABC123" },
|
});
|
||||||
detail: "7BQST3VW9F2MA",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "developer",
|
|
||||||
output: { status: "done" },
|
|
||||||
detail: "1VPBG9SM5E7WK",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "planner",
|
|
||||||
output: { status: "revised", plan: "DEF456" },
|
|
||||||
detail: "4RNMK6PX8B3WQ",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await evaluate(workflow, context);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "$END", prompt: "First plan was ready; end." },
|
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("$last returns undefined for unmatched role", async () => {
|
test("mustache template with nested object paths", () => {
|
||||||
const workflow: WorkflowPayload = {
|
const graph: Record<string, Record<string, Target>> = {
|
||||||
...solveIssueWorkflow,
|
reviewer: {
|
||||||
conditions: {
|
_: {
|
||||||
hasReviewer: {
|
role: "developer",
|
||||||
description: "Reviewer has run",
|
prompt: "Address: {{review.comments}}",
|
||||||
expression: "$exists($last('reviewer'))",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
graph: {
|
|
||||||
$START: [
|
|
||||||
{
|
|
||||||
role: "planner",
|
|
||||||
condition: null,
|
|
||||||
prompt: "Begin planning.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
planner: [
|
|
||||||
{ role: "$END", condition: "hasReviewer", prompt: "Reviewer already ran; end." },
|
|
||||||
{
|
|
||||||
role: "developer",
|
|
||||||
condition: null,
|
|
||||||
prompt: "No reviewer yet; implement.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const context = makeContext([
|
const result = evaluate(graph, "reviewer", {
|
||||||
{
|
status: "_",
|
||||||
role: "planner",
|
review: { comments: "refactor the handler" },
|
||||||
output: { status: "ready" },
|
});
|
||||||
detail: "7BQST3VW9F2MA",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await evaluate(workflow, context);
|
|
||||||
// no reviewer step → $exists returns false → fallback to developer
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "developer", prompt: "No reviewer yet; implement." },
|
value: { role: "developer", prompt: "Address: refactor the handler" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,9 +19,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"jsonata": "^1.8.7"
|
"mustache": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mustache": "^4.2.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -1,65 +1,42 @@
|
|||||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
import type { Target } from "@uncaged/workflow-protocol";
|
||||||
import jsonata from "jsonata";
|
import mustache from "mustache";
|
||||||
|
|
||||||
import type { EvaluateResult, Result } from "./types.js";
|
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 START_ROLE = "$START";
|
||||||
|
const UNIT_STATUS = "_";
|
||||||
|
|
||||||
function isTruthy(value: unknown): boolean {
|
type LastOutput = Record<string, unknown> & { status: string };
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (typeof value === "boolean") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "number") {
|
|
||||||
return value !== 0 && !Number.isNaN(value);
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.length > 0;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findByRole(
|
export function evaluate(
|
||||||
steps: ModeratorContext["steps"],
|
graph: Record<string, Record<string, Target>>,
|
||||||
role: string,
|
lastRole: string,
|
||||||
direction: "first" | "last",
|
lastOutput: LastOutput,
|
||||||
): unknown {
|
): Result<EvaluateResult, Error> {
|
||||||
if (direction === "last") {
|
const status = lastRole === START_ROLE ? UNIT_STATUS : lastOutput.status;
|
||||||
for (let i = steps.length - 1; i >= 0; i--) {
|
|
||||||
if (steps[i].role === role) {
|
const roleTargets = graph[lastRole];
|
||||||
return steps[i].output;
|
if (roleTargets === undefined) {
|
||||||
}
|
return {
|
||||||
}
|
ok: false,
|
||||||
} else {
|
error: new Error(`no transitions defined for role "${lastRole}"`),
|
||||||
for (const step of steps) {
|
};
|
||||||
if (step.role === role) {
|
}
|
||||||
return step.output;
|
|
||||||
}
|
const target = roleTargets[status];
|
||||||
}
|
if (target === undefined) {
|
||||||
}
|
return {
|
||||||
return undefined;
|
ok: false,
|
||||||
}
|
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function evaluateJsonata(
|
|
||||||
expression: string,
|
|
||||||
context: ModeratorContext,
|
|
||||||
): Promise<Result<unknown, Error>> {
|
|
||||||
try {
|
try {
|
||||||
const expr = jsonata(expression);
|
const prompt = mustache.render(target.prompt, lastOutput);
|
||||||
expr.registerFunction(
|
return { ok: true, value: { role: target.role, prompt } };
|
||||||
"first",
|
|
||||||
(role: string) => findByRole(context.steps, role, "first"),
|
|
||||||
"<s:x>",
|
|
||||||
);
|
|
||||||
expr.registerFunction(
|
|
||||||
"last",
|
|
||||||
(role: string) => findByRole(context.steps, role, "last"),
|
|
||||||
"<s:x>",
|
|
||||||
);
|
|
||||||
const result = await expr.evaluate(context);
|
|
||||||
return { ok: true, value: result };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -67,51 +44,3 @@ async function evaluateJsonata(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentRole(context: ModeratorContext): string {
|
|
||||||
if (context.steps.length === 0) {
|
|
||||||
return START_ROLE;
|
|
||||||
}
|
|
||||||
return context.steps[context.steps.length - 1].role;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function evaluate(
|
|
||||||
workflow: WorkflowPayload,
|
|
||||||
context: ModeratorContext,
|
|
||||||
): Promise<Result<EvaluateResult, Error>> {
|
|
||||||
const role = currentRole(context);
|
|
||||||
const transitions = workflow.graph[role];
|
|
||||||
if (transitions === undefined) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: new Error(`no transitions defined for role "${role}"`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const transition of transitions) {
|
|
||||||
if (transition.condition === null) {
|
|
||||||
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const conditionDef = workflow.conditions[transition.condition];
|
|
||||||
if (conditionDef === undefined) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: new Error(`unknown condition "${transition.condition}"`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const evalResult = await evaluateJsonata(conditionDef.expression, context);
|
|
||||||
if (!evalResult.ok) {
|
|
||||||
return evalResult;
|
|
||||||
}
|
|
||||||
if (isTruthy(evalResult.value)) {
|
|
||||||
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: new Error(`no transition matched for role "${role}"`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,23 +47,16 @@ type RoleDefinition = {
|
|||||||
frontmatter: CasRef;
|
frontmatter: CasRef;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Transition = {
|
type Target = {
|
||||||
role: string;
|
role: string;
|
||||||
condition: string | null;
|
|
||||||
prompt: string;
|
prompt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConditionDefinition = {
|
|
||||||
description: string;
|
|
||||||
expression: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkflowPayload = {
|
type WorkflowPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
roles: Record<string, RoleDefinition>;
|
roles: Record<string, RoleDefinition>;
|
||||||
conditions: Record<string, ConditionDefinition>;
|
graph: Record<string, Record<string, Target>>;
|
||||||
graph: Record<string, Transition[]>;
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,7 +85,7 @@ type StepNodePayload = StepRecord & {
|
|||||||
### Moderator context
|
### Moderator context
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type StepContext = Omit<StepRecord, "output"> & { output: unknown };
|
type StepContext = Omit<StepRecord, "output"> & { output: unknown; content: string | null };
|
||||||
|
|
||||||
type ModeratorContext = {
|
type ModeratorContext = {
|
||||||
start: StartNodePayload;
|
start: StartNodePayload;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export type {
|
|||||||
AgentAlias,
|
AgentAlias,
|
||||||
AgentConfig,
|
AgentConfig,
|
||||||
CasRef,
|
CasRef,
|
||||||
ConditionDefinition,
|
|
||||||
ModelAlias,
|
ModelAlias,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
ModeratorContext,
|
ModeratorContext,
|
||||||
@@ -26,12 +25,12 @@ export type {
|
|||||||
StepNodePayload,
|
StepNodePayload,
|
||||||
StepOutput,
|
StepOutput,
|
||||||
StepRecord,
|
StepRecord,
|
||||||
|
Target,
|
||||||
ThreadForkOutput,
|
ThreadForkOutput,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
ThreadStepsOutput,
|
ThreadStepsOutput,
|
||||||
ThreadsIndex,
|
ThreadsIndex,
|
||||||
Transition,
|
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
WorkflowName,
|
WorkflowName,
|
||||||
WorkflowPayload,
|
WorkflowPayload,
|
||||||
|
|||||||
@@ -14,22 +14,11 @@ const ROLE_DEFINITION: JSONSchema = {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONDITION_DEFINITION: JSONSchema = {
|
const TARGET: JSONSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["description", "expression"],
|
required: ["role", "prompt"],
|
||||||
properties: {
|
|
||||||
description: { type: "string" },
|
|
||||||
expression: { type: "string" },
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TRANSITION: JSONSchema = {
|
|
||||||
type: "object",
|
|
||||||
required: ["role", "condition", "prompt"],
|
|
||||||
properties: {
|
properties: {
|
||||||
role: { type: "string" },
|
role: { type: "string" },
|
||||||
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
||||||
prompt: { type: "string" },
|
prompt: { type: "string" },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -38,7 +27,7 @@ const TRANSITION: JSONSchema = {
|
|||||||
export const WORKFLOW_SCHEMA: JSONSchema = {
|
export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||||
title: "Workflow",
|
title: "Workflow",
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["name", "description", "roles", "conditions", "graph"],
|
required: ["name", "description", "roles", "graph"],
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: "string" },
|
name: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
@@ -46,15 +35,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
|
|||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: ROLE_DEFINITION,
|
additionalProperties: ROLE_DEFINITION,
|
||||||
},
|
},
|
||||||
conditions: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: CONDITION_DEFINITION,
|
|
||||||
},
|
|
||||||
graph: {
|
graph: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
type: "array",
|
type: "object",
|
||||||
items: TRANSITION,
|
additionalProperties: TARGET,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,23 +27,16 @@ export type RoleDefinition = {
|
|||||||
frontmatter: CasRef;
|
frontmatter: CasRef;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Transition = {
|
export type Target = {
|
||||||
role: string;
|
role: string;
|
||||||
condition: string | null;
|
|
||||||
prompt: string;
|
prompt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionDefinition = {
|
|
||||||
description: string;
|
|
||||||
expression: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowPayload = {
|
export type WorkflowPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
roles: Record<string, RoleDefinition>;
|
roles: Record<string, RoleDefinition>;
|
||||||
conditions: Record<string, ConditionDefinition>;
|
graph: Record<string, Record<string, Target>>;
|
||||||
graph: Record<string, Transition[]>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
|
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
|
||||||
@@ -63,6 +56,7 @@ export type StepNodePayload = StepRecord & {
|
|||||||
/** JSONata 上下文中的 step — output 被展开 */
|
/** JSONata 上下文中的 step — output 被展开 */
|
||||||
export type StepContext = Omit<StepRecord, "output"> & {
|
export type StepContext = Omit<StepRecord, "output"> & {
|
||||||
output: unknown;
|
output: unknown;
|
||||||
|
content: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModeratorContext = {
|
export type ModeratorContext = {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { AgentContext } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
|
/** Max characters of step content to include in the prompt. */
|
||||||
|
const CONTENT_QUOTA = 16_000;
|
||||||
|
|
||||||
|
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
||||||
|
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(ctx.currentRole.systemPrompt);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Task");
|
||||||
|
lines.push(ctx.start.content);
|
||||||
|
|
||||||
|
const { steps } = ctx;
|
||||||
|
if (steps.length === 0) {
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length === 1) {
|
||||||
|
const s = steps[0];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`## Step: ${s.role}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
|
||||||
|
appendContent(lines, s.content);
|
||||||
|
} else {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Previous Steps");
|
||||||
|
for (let i = 0; i < steps.length - 1; i++) {
|
||||||
|
const s = steps[i];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`### Step ${i + 1}: ${s.role}`);
|
||||||
|
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
|
||||||
|
}
|
||||||
|
const last = steps[steps.length - 1];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`## Latest Step: ${last.role}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
|
||||||
|
appendContent(lines, last.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Tools");
|
||||||
|
lines.push(
|
||||||
|
`Use \`uncaged-workflow thread ${ctx.threadId}\` to read full details of any previous step.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendContent(lines: string[], content: string | null | undefined): void {
|
||||||
|
if (content === null || content === undefined || content.trim() === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const truncated =
|
||||||
|
content.length > CONTENT_QUOTA
|
||||||
|
? `${content.slice(0, CONTENT_QUOTA)}\n... (truncated)`
|
||||||
|
: content;
|
||||||
|
lines.push("");
|
||||||
|
lines.push("<output>");
|
||||||
|
lines.push(truncated);
|
||||||
|
lines.push("</output>");
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ All exports come from `src/index.ts`.
|
|||||||
```typescript
|
```typescript
|
||||||
function encodeUint64AsCrockford(value: bigint): string
|
function encodeUint64AsCrockford(value: bigint): string
|
||||||
function generateUlid(nowMs: number): string
|
function generateUlid(nowMs: number): string
|
||||||
|
function extractUlidTimestamp(ulid: string): number | null
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
|
||||||
|
|
||||||
|
describe("extractUlidTimestamp", () => {
|
||||||
|
it("should extract correct timestamp from ULID", () => {
|
||||||
|
const knownTimestamp = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ulid = generateUlid(knownTimestamp);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(knownTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle epoch timestamp (timestamp 0)", () => {
|
||||||
|
const ulid = generateUlid(0);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle recent timestamps", () => {
|
||||||
|
const recentTimestamp = Date.now();
|
||||||
|
const ulid = generateUlid(recentTimestamp);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(recentTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle max 48-bit timestamp", () => {
|
||||||
|
const maxTimestamp = 2 ** 48 - 1;
|
||||||
|
const ulid = generateUlid(maxTimestamp);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(maxTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid ULID length", () => {
|
||||||
|
expect(extractUlidTimestamp("")).toBe(null);
|
||||||
|
expect(extractUlidTimestamp("TOOSHORT")).toBe(null);
|
||||||
|
expect(extractUlidTimestamp("TOOLONGAAAAAAAAAAAAAAAAAA")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid Crockford Base32 characters", () => {
|
||||||
|
expect(extractUlidTimestamp("INVALID!@#$%^&CHARACTERS")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract timestamps from multiple ULIDs correctly", () => {
|
||||||
|
const timestamps = [
|
||||||
|
Date.UTC(2020, 0, 1, 0, 0, 0),
|
||||||
|
Date.UTC(2023, 5, 15, 12, 30, 45),
|
||||||
|
Date.UTC(2026, 11, 31, 23, 59, 59),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const ts of timestamps) {
|
||||||
|
const ulid = generateUlid(ts);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(ts);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,4 +24,4 @@ export { normalizeRefsField } from "./refs-field.js";
|
|||||||
export { err, ok } from "./result.js";
|
export { err, ok } from "./result.js";
|
||||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||||
export type { LogFn, Result } from "./types.js";
|
export type { LogFn, Result } from "./types.js";
|
||||||
export { generateUlid } from "./ulid.js";
|
export { extractUlidTimestamp, generateUlid } from "./ulid.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { encodeCrockfordBase32Bits } from "./base32.js";
|
import { decodeCrockfordBase32Bits, encodeCrockfordBase32Bits } from "./base32.js";
|
||||||
|
|
||||||
const ULID_TIME_BITS = 48;
|
const ULID_TIME_BITS = 48;
|
||||||
const ULID_RANDOM_BITS = 80;
|
const ULID_RANDOM_BITS = 80;
|
||||||
@@ -26,3 +26,19 @@ export function generateUlid(nowMs: number): string {
|
|||||||
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
|
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
|
||||||
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
|
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the timestamp (in milliseconds) from a ULID string.
|
||||||
|
* Returns null if the ULID is invalid.
|
||||||
|
*/
|
||||||
|
export function extractUlidTimestamp(ulid: string): number | null {
|
||||||
|
if (ulid.length !== 26) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const timestampPart = ulid.slice(0, 10);
|
||||||
|
const decoded = decodeCrockfordBase32Bits(timestampPart, ULID_TIME_BITS);
|
||||||
|
if (!decoded.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Number(decoded.value);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user