Compare commits

...

24 Commits

Author SHA1 Message Date
xiaoju 298b944169 docs: update all documentation for status-based routing (#497)
Replace all JSONata/ConditionDefinition/ConditionalEdge references with
status-based routing terminology across 8 files:

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

Phase 3 of #490 (closes #493)
2026-05-25 05:05:57 +00:00
xiaoju 5a7f417899 feat: migrate examples to status-based routing + fix mustache HTML escape
- Migrate solve-issue.yaml, analyze-topic.yaml, debate.yaml to new format
- Add status enum field to all role frontmatter schemas
- Use {{{ }}} (triple mustache) for prompt templates with user content
- Disable mustache HTML escaping globally (prompts are plain text, not HTML)
- Add 2 new tests for HTML escape behavior
- 9 moderator tests pass

Phase 2 of #490 (closes #492)
2026-05-25 04:52:53 +00:00
xiaoju d00f9df2dd refactor: status-based graph routing + mustache prompt templates
- Delete ConditionDefinition, Transition types from workflow-protocol
- Add Target type, change graph to Record<string, Record<string, Target>>
- Remove conditions from WorkflowPayload and WORKFLOW_SCHEMA
- Replace jsonata with mustache in workflow-moderator
- Rewrite evaluate() to simple map lookup + mustache render
- Update cli-workflow to use new 3-arg evaluate(graph, role, output)
- 296 tests pass, 0 fail

Phase 1 of #490 (closes #491)
2026-05-25 04:50:06 +00:00
xiaoju ff959be3ef Merge pull request 'refactor(cli-workflow): reduce cmdStepRead cognitive complexity' (#488) from fix/487-refactor-step-read into main 2026-05-25 02:25:32 +00:00
xiaoju f45563ee31 refactor(cli-workflow): reduce cmdStepRead cognitive complexity
Extract four helper functions from cmdStepRead to reduce cognitive
complexity from 27 to ≤15:
- loadStepDetail: Load and validate step detail node
- loadTurnData: Load all turn nodes and extract content
- selectTurnsForQuota: Select turns within quota (≥1 always shown)
- formatStepMarkdown: Assemble final markdown output

All 6 existing tests pass. Zero Biome warnings. CLAUDE.md compliant.

Fixes #487
2026-05-25 02:17:55 +00:00
xiaoju 2b8cd99100 fix(agent-claude-code): use buildContinuationPrompt for step context
Replace custom buildHistorySummary with shared buildContinuationPrompt
from workflow-agent-kit. This aligns claude-code agent with hermes agent:

- First visit: includes step content (within 32k quota)
- Re-entry: shows only steps since last visit (meta only, session has context)

Previously developer could not see reviewer's detailed feedback on
re-entry, only {"approved":false}. Now gets full review text.

Fixes #486
2026-05-25 02:01:57 +00:00
xiaoju 1ca13e02b2 Merge pull request 'feat(cli): implement step read command' (#485) from fix/484-step-read-command into main 2026-05-25 01:43:12 +00:00
xiaoju 3146832d1b fix(cli-workflow): complete step read command implementation
Implements the `uwf step read` command to render a single step's turns
as human-readable markdown with quota enforcement.

Changes:
- Implement cmdStepRead() in step.ts with quota enforcement
  - Renders step metadata (hash, role, agent)
  - Loads and formats turns from detail node
  - Enforces quota by selecting most recent turns
  - Always shows at least one turn even if it exceeds quota
  - Gracefully handles steps with no detail or no turns
- Register `step read` command in cli.ts with --quota flag (default 4000)
- Add comprehensive test suite in step-read.test.ts (6 tests covering
  basic functionality, quota enforcement, edge cases, and special chars)
- Update README.md CLI Reference table to include `step read`
- Update package-level README.md with command documentation and example

Closes #484

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 01:36:25 +00:00
xiaoju 64f929c10d Merge pull request 'fix(cli-workflow): fix thread read --quota flag implementation' (#483) from fix/480-thread-read-quota into main 2026-05-25 01:18:21 +00:00
xiaoju 1ec32ae0fd Merge pull request 'fix: cas has now returns exit 1 for non-existent hashes' (#482) from fix/481-cas-has-exit-code into main 2026-05-25 01:18:07 +00:00
xiaoju f851a087f2 fix(cli-workflow): fix thread read --quota flag implementation
Issue #480: The --quota flag on 'uwf thread read' was not properly
limiting output size due to an off-by-one error in selectByQuota().

Root cause:
- Items were added to selected array BEFORE checking if they would
  exceed the quota
- This meant the last item that exceeded quota was still included
- Prompt deduplication tracking was mutated during quota calculation,
  causing prompts to not render in final output

Fix:
- Check quota BEFORE adding items to selected array
- Always include at least one step even if it exceeds quota
- Calculate step lengths using actual rendering format
- Account for start section and separators in quota calculation
- Use temporary Set during length calculation to avoid mutating
  the prompt deduplication tracking

Tests:
- Added comprehensive test suite (thread-read-quota.test.ts)
- Covers quota enforcement, boundary conditions, edge cases
- Tests interaction with --before and --start flags
- All tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 00:58:30 +00:00
xiaoju 984e6ae56d fix: cas has now returns exit 1 for non-existent hashes
Changed the exit behavior of 'uwf cas has' to return exit code 1 when
a hash doesn't exist, while preserving the JSON output {exists:false}.
This enables proper use in shell conditionals like 'if uwf cas has $HASH'.

Fixes #481
2026-05-25 00:47:39 +00:00
xiaoju 92f3b36b10 chore: add uwf script for local dev testing
`bun run uwf -- <args>` runs the local version of uwf CLI,
useful in worktrees to test local changes vs the global install.
2026-05-25 00:03:10 +00:00
xiaoju a4677f8adb docs: sync README files with recent changes 2026-05-24 17:04:09 +00:00
xiaoju 9ab6291a41 fix(workflow): add --repo flag to tea pr create in worktree dirs
Fixes #474
2026-05-24 16:56:19 +00:00
xiaoju 50a4db72b1 fix(workflow): add check step to developer, clarify reviewer hard/soft checks
Developer procedure now requires running lint/build checks before committing.
Reviewer procedure clarified: hard checks (build/lint) must pass, style-only
suggestions should not block approval.

Fixes #477
2026-05-24 16:43:07 +00:00
xiaoju dfdf0ac073 fix(cli-workflow): resolve step/thread commands on completed threads
Fixed issue #469 where `uwf step list`, `uwf step show`, and `uwf thread read`
failed with "thread not active" error when called on completed threads.

The root cause was that resolveHeadHash() in shared.ts only checked threads.yaml
(active threads index) but never fell back to history.jsonl (completed threads log).

Changes:
- Updated resolveHeadHash() in shared.ts to check history.jsonl as fallback
- Changed error message from "thread not active" to "thread not found"
- Added comprehensive test coverage:
  - Unit tests for resolveHeadHash() with active/completed/missing threads
  - Integration tests for cmdStepList() with completed threads
  - Integration tests for cmdStepShow() with completed threads
  - Regression tests for cmdThreadRead() with completed threads

All commands now work identically for active and completed threads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 16:18:20 +00:00
xiaoju c2c849df7e fix(agent-kit): provide full thread context to first-time participating roles
When a role participates for the first time (e.g. committer), it previously
only received the system prompt + last step output, missing the full thread
history. This caused hallucination as the role had to guess what happened.

Changes:
- build-continuation-prompt.ts: detect first-time roles and include all
  steps' meta + content for last 2-3 steps (within quota)
- context.ts: add isFirstVisit detection helper
- types.ts: add isFirstVisit field to AgentContext
- hermes.ts: pass isFirstVisit through to prompt builder

Fixes #473
2026-05-24 15:56:39 +00:00
xiaoju 39f6ae692b feat(cli): add filtering and pagination to thread list command
Implements enhanced filtering and pagination for the `uwf thread list` command
to support workflows with large numbers of threads.

Changes:
- Add --page, --page-size parameters for pagination (default: page 1, size 20)
- Add --since, --until time filters supporting multiple formats (ISO8601, relative like "2h", "1d")
- Add --workflow filter to show threads for specific workflow
- Add --sort parameter (newest-first, oldest-first, alphabetical)
- Add pagination metadata in JSON output (page, pageSize, totalThreads, totalPages, hasMore)
- Implement parseRelativeTime() for human-friendly time expressions (1h, 30m, 2d, 1w)
- Add comprehensive unit tests for filters, pagination, and time parsing
- Update CLI help text with new parameters and examples

Fixes #471
2026-05-24 14:44:30 +00:00
xiaomo eb027e70f4 fix: include step content in continuation prompt (closes #466)
- Add `content: string | null` to RoleStep type
- Resolve contentHash → text for the last step when building ThreadContext
- Update buildAgentPrompt to include <output> tag with step content
- Add 16k content quota with truncation
- Update tests
2026-05-24 13:41:00 +00:00
xiaomo 8fbbbce07e Merge pull request 'chore: cleanup dead code and update CLI docs' (#468) from chore/cleanup-cli-docs into main 2026-05-24 11:42:36 +00:00
xiaoju f115718564 chore: cleanup dead code and update CLI docs
- Remove cmdThreadRunning dead code (CLI uses --status running now)
- Remove step read from README (command not registered)
- Update cli-reference.ts to reflect new four-layer commands

Refs #463
2026-05-24 11:41:02 +00:00
xiaomo 5c0eabda8e Merge pull request 'feat: restructure CLI commands (workflow/thread/step/turn)' (#467) from fix/463-http-methods into main 2026-05-24 11:37:50 +00:00
71 changed files with 4049 additions and 1084 deletions
+26 -64
View File
@@ -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."
+3 -3
View File
@@ -8,10 +8,10 @@ This monorepo implements a stateless workflow engine driven by a single-step CLI
| Concept | What it is | | 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
+10 -9
View File
@@ -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 |
+7 -8
View File
@@ -16,7 +16,7 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|-------|---------|---------------| |-------|---------|---------------|
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. | | 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. |
+2 -2
View File
@@ -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
View File
@@ -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[]>
``` ```
+5 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}); });
}); });
+1
View File
@@ -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",
+9 -4
View File
@@ -16,7 +16,7 @@ workflow → thread → step → turn
- **Workflow** (layer 1): YAML template with roles and routing graph - **Workflow** (layer 1): YAML template with roles and routing graph
- **Thread** (layer 2): Single workflow execution instance - **Thread** (layer 2): Single workflow execution instance
- **Step** (layer 3): One moderator→agent→extract cycle - **Step** (layer 3): One moderator→agent→extract cycle
- **Turn** (layer 4): Agent-internal interactions (use `step read` or CAS to inspect) - **Turn** (layer 4): Agent-internal interactions (use `step show` or CAS to inspect)
This package has no library `src/index.ts` — it is consumed as a CLI binary only. This package has no library `src/index.ts` — it is consumed as a CLI binary only.
@@ -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,7 +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> [--before N]` | Read step output as markdown | | `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:
@@ -80,7 +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 --before 3 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();
}); });
+117 -23
View File
@@ -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);
}
}); });
}); });
+9 -5
View File
@@ -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 {
+124 -13
View File
@@ -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;
}
+194 -81
View File
@@ -8,27 +8,25 @@ import type {
AgentAlias, AgentAlias,
AgentConfig, AgentConfig,
CasRef, CasRef,
ModeratorContext,
RunningThreadsOutput,
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 { config as loadDotenv } from "dotenv";
import { parse, stringify } from "yaml";
import { import {
createMarker, createProcessLogger,
deleteMarker, extractUlidTimestamp,
isThreadRunning, generateUlid,
listRunningThreads, type ProcessLogger,
} from "../background/index.js"; } from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import { parse } from "yaml";
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
import { import {
appendThreadHistory, appendThreadHistory,
createUwfStore, createUwfStore,
@@ -53,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";
@@ -350,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 {
@@ -452,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 };
} }
@@ -534,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) {
@@ -570,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 {
@@ -823,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}`);
} }
@@ -875,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}`);
} }
@@ -1016,8 +1134,3 @@ export async function cmdThreadCancel(
return { thread: threadId, cancelled: true }; return { thread: threadId, cancelled: true };
} }
export async function cmdThreadRunning(storageRoot: string): Promise<RunningThreadsOutput> {
const threads = await listRunningThreads(storageRoot);
return { threads };
}
+16 -19
View File
@@ -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),
}; };
} }
+4 -19
View File
@@ -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!");
});
}); });
+23 -37
View File
@@ -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(
+4 -3
View File
@@ -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;
+4 -5
View File
@@ -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. 当前状态与待完善项
+1 -1
View File
@@ -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(),
}), }),
), ),
}), }),
+15 -40
View File
@@ -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");
+1 -1
View File
@@ -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,
}; };
@@ -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" },
], ],
}, },
]; ];
+11 -10
View File
@@ -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" },
}); });
}); });
}); });
+2 -1
View File
@@ -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": {
+31 -102
View File
@@ -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}"`),
};
}
+3 -10
View File
@@ -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;
+1 -2
View File
@@ -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,
+5 -20
View File
@@ -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,
}, },
}, },
}, },
+3 -9
View File
@@ -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>");
}
+1
View File
@@ -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);
}
});
});
+19 -13
View File
@@ -15,7 +15,7 @@ uwf setup --provider <name> --base-url <url> \\
## Workflow Commands ## Workflow Commands
\`\`\` \`\`\`
uwf workflow put <file> # register a workflow from YAML file uwf workflow add <file> # register a workflow from YAML file
uwf workflow show <id> # show workflow by name or CAS hash uwf workflow show <id> # show workflow by name or CAS hash
uwf workflow list # list all registered workflows uwf workflow list # list all registered workflows
\`\`\` \`\`\`
@@ -24,20 +24,27 @@ uwf workflow list # list all registered workflows
\`\`\` \`\`\`
uwf thread start <workflow> -p <prompt> # create a thread (no execution) uwf thread start <workflow> -p <prompt> # create a thread (no execution)
uwf thread step <thread-id> # execute one moderatoragentextract cycle uwf thread exec <thread-id> # execute one moderatoragentextract cycle
[--agent <cmd>] # override agent command [--agent <cmd>] # override agent command
[-c, --count <number>] # run multiple steps (default: 1) [-c, --count <number>] # run multiple steps (default: 1)
[--background] # run in background
uwf thread show <thread-id> # show thread head pointer uwf thread show <thread-id> # show thread head pointer
uwf thread list # list active threads uwf thread list # list threads
[--all] # include archived threads [--status <status>] # filter: idle, running, or completed
uwf thread kill <thread-id> # terminate and archive a thread
uwf thread steps <thread-id> # list all steps in a thread
uwf thread read <thread-id> # render thread context as markdown uwf thread read <thread-id> # render thread context as markdown
[--quota <chars>] # max output characters (default 32000) [--quota <chars>] # max output characters (default 32000)
[--before <step-hash>] # load steps before this hash (exclusive) [--before <step-hash>] # load steps before this hash (exclusive)
[--start] # include start step in output [--start] # include start step in output
uwf thread fork <step-hash> # fork a thread from a specific step uwf thread stop <thread-id> # stop background execution (keep thread active)
uwf thread step-details <step-hash> # dump full detail node of a step as YAML uwf thread cancel <thread-id> # cancel thread (stop + move to history)
\`\`\`
## Step Commands
\`\`\`
uwf step list <thread-id> # list all steps in a thread
uwf step show <step-hash> # show details of a specific step
uwf step fork <step-hash> # fork a thread from a specific step
\`\`\` \`\`\`
## CAS Commands ## CAS Commands
@@ -78,10 +85,9 @@ uwf -V, --version # print version
## Key Concepts ## Key Concepts
- **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash. - **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash.
- **Thread**: A single workflow execution (ULID). State is an immutable CAS chain; active threads are indexed in \`threads.yaml\`. - **Thread**: A running instance of a workflow; points to a chain of CAS step nodes.
- **Step**: One moderatoragentextract cycle. Run \`uwf thread step\` repeatedly until \`$END\`. - **Step**: One moderatoragentextract cycle; stored as a CAS node with output + detail refs.
- **CAS**: Content-Addressed Storage all nodes are immutable and identified by hash. - **Turn**: Agent-internal interaction (within a single step); stored per-turn in the detail node.
- **Role**: Named actor with goal, capabilities, procedure, output, and frontmatter schema; the moderator routes between roles. - **CAS**: Content-addressable store; every artifact (workflows, steps, details, turns) is hashed.
- **Edge Prompt**: Required instruction on each graph edge the moderator's dispatch message to the agent.
`; `;
} }
+1 -1
View File
@@ -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";
+17 -1
View File
@@ -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);
}