Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0e3f4a363 | |||
| 38112053a0 | |||
| 1d174ee5c9 | |||
| 6e3b32ca34 | |||
| 932bbe5c41 | |||
| 9440b9af82 | |||
| f96d6eb7c4 | |||
| 95102941f1 | |||
| 521d908719 | |||
| 02a2c00175 | |||
| 8ca7708a12 | |||
| 0fdc0fdec3 | |||
| d6eaf3fdc7 | |||
| 5dc2352ac5 | |||
| 39e2ab7f0d | |||
| 221919448e | |||
| 68b82c9574 | |||
| 335b8a4ae6 | |||
| bf31fa0d03 | |||
| c39f2f3e63 | |||
| 6481fc0cc5 | |||
| 3190e06ebe | |||
| f8ae2fe25b | |||
| ffc31a8c19 | |||
| 48a274685b | |||
| 5b68359dfc | |||
| c2ddfb8558 | |||
| 603018caf2 | |||
| aff0ee6fea | |||
| d37fa1393a | |||
| 759c784267 | |||
| 52ffc7dcc1 | |||
| ac55a3e3d9 | |||
| edb979baa9 | |||
| 3d1850ddbe | |||
| 3c1f4a6dfa | |||
| f07a6daa30 | |||
| 0eeb4a8ed8 | |||
| a3fac708b6 | |||
| 52879c0028 | |||
| 8720eb19af | |||
| 9e4527bb89 | |||
| 5209cfa7ac | |||
| 155b879d29 | |||
| c1f04929f4 | |||
| 50cd93aa05 | |||
| 1abc3b4cf4 | |||
| 330db43b5f |
@@ -0,0 +1,67 @@
|
||||
# Sync README
|
||||
|
||||
When updating README.md files in this monorepo, follow these conventions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Root `README.md` — project overview and navigation hub
|
||||
- Per-package `packages/*/README.md` — each package self-contained
|
||||
|
||||
## Root README Structure
|
||||
|
||||
The root README should have these sections in order:
|
||||
|
||||
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
|
||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||
3. **Architecture** — dependency layer diagram (text-based)
|
||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
|
||||
5. **Quick Start** — install, build, register workflow, start thread, run step
|
||||
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
|
||||
7. **Development** — bun install / build / check / test
|
||||
|
||||
## Per-Package README Structure
|
||||
|
||||
Each package README should have:
|
||||
|
||||
1. **Title** — package name
|
||||
2. **One-line description** — matching package.json
|
||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
|
||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||
6. **CLI Usage** (cli/agent packages) — command reference with examples
|
||||
7. **Internal Structure** — brief src/ file organization
|
||||
8. **Configuration** (if applicable)
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Gather current state
|
||||
For each package read:
|
||||
- package.json (name, version, description, dependencies, bin)
|
||||
- src/index.ts (public API exports)
|
||||
- Existing README.md (preserve hand-written content worth keeping)
|
||||
|
||||
### Step 2: Update root README
|
||||
- Ensure ALL packages in packages/ directory are listed in the table
|
||||
- Update CLI command reference from uwf --help output
|
||||
- Keep Quick Start examples valid
|
||||
|
||||
### Step 3: Write/update each package README
|
||||
- Follow the per-package structure
|
||||
- API section MUST match actual src/index.ts exports — never invent
|
||||
- For agent packages: document CLI binary name, how it is invoked
|
||||
- For lib packages: document exported types and functions
|
||||
- Internal structure: list actual files in src/
|
||||
|
||||
### Step 4: Verify
|
||||
- All relative links work
|
||||
- Package names match package.json
|
||||
- No references to removed/renamed packages
|
||||
- bun run build still passes
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only document what src/index.ts actually exports
|
||||
- Root README summarizes, package READMEs go into detail
|
||||
- Verify CLI examples against actual commands
|
||||
- Preserve existing good prose when updating
|
||||
- English for all README content
|
||||
+27
-10
@@ -38,18 +38,26 @@ roles:
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
Before starting any work, ensure a clean worktree:
|
||||
1. `git checkout main && git pull` to get the latest code
|
||||
2. `git checkout -b fix/<issue-number>-<short-description>` to create a fresh branch
|
||||
- If bounced back from reviewer or tester, reuse the existing branch instead
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. `cd ~/repos/workflow && git fetch origin` to get latest refs
|
||||
2. First time (no existing branch):
|
||||
- `git worktree add ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
3. If bounced back from reviewer or tester (branch already exists):
|
||||
- The worktree should already exist at `~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
4. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
3. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
||||
4. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
||||
5. Write tests first based on the spec
|
||||
6. Implement the code to make tests pass
|
||||
7. Ensure `bun run build` passes with no errors
|
||||
8. Run `bun test` to verify all tests pass
|
||||
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
||||
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
||||
7. Write tests first based on the spec
|
||||
8. Implement the code to make tests pass
|
||||
9. Ensure `bun run build` passes with no errors
|
||||
10. Run `bun test` to verify all tests pass
|
||||
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
|
||||
frontmatter:
|
||||
type: object
|
||||
@@ -65,6 +73,8 @@ roles:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
@@ -98,6 +108,8 @@ roles:
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
@@ -118,6 +130,8 @@ roles:
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||
@@ -125,6 +139,9 @@ roles:
|
||||
- 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 "..."`
|
||||
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
5. After PR creation, clean up the worktree:
|
||||
- `cd ~/repos/workflow`
|
||||
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
||||
frontmatter:
|
||||
type: object
|
||||
|
||||
@@ -2,92 +2,102 @@
|
||||
|
||||
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.
|
||||
|
||||
## Package Map
|
||||
## Overview
|
||||
|
||||
| Package | npm | Role |
|
||||
|---------|-----|------|
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI binary — thread lifecycle, workflow registry, CAS inspection, setup |
|
||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `WorkflowConfig`, etc.) |
|
||||
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — determines next role or `$END` |
|
||||
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, two-layer extract pipeline |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` agent — spawns Hermes chat, captures session |
|
||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing |
|
||||
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.
|
||||
|
||||
External: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (CAS store + JSON Schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
||||
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.
|
||||
|
||||
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
|
||||
|
||||
## Architecture
|
||||
|
||||
Dependency layers (lower layers have no dependency on higher layers):
|
||||
|
||||
```
|
||||
Layer 0 — Contract
|
||||
workflow-protocol Shared types and JSON Schema definitions
|
||||
|
||||
Layer 1 — Shared infra
|
||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
||||
workflow-moderator JSONata graph evaluator
|
||||
|
||||
Layer 2 — Agent framework
|
||||
workflow-agent-kit createAgent factory, context builder, extract pipeline
|
||||
|
||||
Layer 3 — Agent implementations
|
||||
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
|
||||
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
|
||||
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
|
||||
|
||||
Layer 4 — CLI
|
||||
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup
|
||||
|
||||
App (uses protocol; not in the runtime engine stack)
|
||||
workflow-dashboard Web UI for visual workflow editing
|
||||
```
|
||||
|
||||
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | npm | Description | Type | README |
|
||||
|---------|-----|-------------|------|--------|
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
|
||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
|
||||
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/README.md) |
|
||||
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-agent-kit/README.md) |
|
||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
||||
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
|
||||
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
|
||||
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure provider and model
|
||||
# 1. Configure provider, model, and default agent
|
||||
uwf setup
|
||||
|
||||
# 2. Register a workflow from YAML
|
||||
uwf workflow put examples/solve-issue.yaml
|
||||
|
||||
# 3. Start a thread
|
||||
# 3. Start a thread (creates head pointer; does not execute)
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
|
||||
# 4. Execute steps (one at a time, until done)
|
||||
uwf thread step <thread-id>
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
Use `-c, --count <number>` on `thread step` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||
|
||||
### Thread
|
||||
## CLI Reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread (no execution) |
|
||||
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle |
|
||||
| `uwf thread show <thread-id>` | Show head pointer and done status |
|
||||
| `uwf thread list [--all]` | List threads (`--all` includes archived) |
|
||||
| `uwf thread steps <thread-id>` | List all steps chronologically |
|
||||
| `uwf thread read <thread-id> [--quota N]` | Render thread as readable markdown |
|
||||
| `uwf thread fork <step-hash>` | Fork from a specific step |
|
||||
| `uwf thread step-details <step-hash>` | Dump full detail node |
|
||||
| `uwf thread kill <thread-id>` | Terminate and archive |
|
||||
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
||||
|
||||
### Workflow
|
||||
| Group | Commands |
|
||||
|-------|----------|
|
||||
| **thread** | `start`, `step`, `show`, `list`, `kill`, `steps`, `read`, `fork`, `step-details` |
|
||||
| **workflow** | `put`, `show`, `list` |
|
||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
||||
| **log** | `list`, `show`, `clean` — process-level debug logs |
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf workflow put <file.yaml>` | Register a workflow from YAML |
|
||||
| `uwf workflow show <name-or-hash>` | Show workflow definition |
|
||||
| `uwf workflow list` | List registered workflows |
|
||||
Config is stored in `~/.uncaged/workflow/config.yaml`. API keys go in `~/.uncaged/workflow/.env`.
|
||||
|
||||
### CAS
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf cas get <hash>` | Read a CAS node |
|
||||
| `uwf cas put <type-hash> <data>` | Store a node |
|
||||
| `uwf cas has <hash>` | Check existence |
|
||||
| `uwf cas refs <hash>` | List direct references |
|
||||
| `uwf cas walk <hash>` | Recursive traversal |
|
||||
| `uwf cas reindex` | Rebuild type index |
|
||||
| `uwf cas schema list` | List schemas |
|
||||
| `uwf cas schema get <hash>` | Show a schema |
|
||||
|
||||
### Setup
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf setup` | Interactive provider/model/agent configuration |
|
||||
| `uwf setup --provider ... --base-url ... --api-key ... --model ...` | Non-interactive setup |
|
||||
|
||||
Config stored in `~/.uncaged/workflow/config.yaml`. API keys in `~/.uncaged/workflow/.env`.
|
||||
Detailed command usage, options, and examples: [packages/cli-workflow/README.md](packages/cli-workflow/README.md).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun install --no-cache # Install dependencies
|
||||
bun run build # tsc --build (all packages)
|
||||
bun run check # tsc + biome + lint-log-tags
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run all tests
|
||||
```
|
||||
|
||||
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
|
||||
|
||||
## Architecture
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
||||
|
||||
@@ -17,6 +17,15 @@
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test",
|
||||
"test": "bun run --filter './packages/*' test",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && node scripts/publish-all.mjs"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
`uwf` CLI — thread lifecycle, workflow registry, CAS inspection, and setup.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 4 entry point for the workflow engine. The `uwf` binary orchestrates one step per invocation: load thread head from `threads.yaml`, run the moderator, spawn the configured agent CLI, run extract, append a CAS step node, and update the head pointer (or archive when `$END`).
|
||||
|
||||
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-moderator`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `yaml`
|
||||
|
||||
## Installation
|
||||
|
||||
Included as the `uwf` binary when you install `@uncaged/cli-workflow`:
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/cli-workflow
|
||||
# or from the monorepo:
|
||||
bun link packages/cli-workflow
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
### Global options
|
||||
|
||||
```
|
||||
-V, --version Show version
|
||||
--format <json|yaml> Output format (default: json)
|
||||
-h, --help Show help
|
||||
```
|
||||
|
||||
### Thread
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
|
||||
| `uwf thread step <thread-id> [--agent <cmd>] [-c <count>]` | Execute one or more moderator→agent→extract cycles |
|
||||
| `uwf thread show <thread-id>` | Show thread head pointer |
|
||||
| `uwf thread list [--all]` | List active threads (`--all` includes archived) |
|
||||
| `uwf thread steps <thread-id>` | List all steps chronologically |
|
||||
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
|
||||
| `uwf thread fork <step-hash>` | Fork from a specific step |
|
||||
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML |
|
||||
| `uwf thread kill <thread-id>` | Terminate and archive |
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
uwf thread step 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
uwf thread step 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
|
||||
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf workflow put <file.yaml>` | Register a workflow from YAML |
|
||||
| `uwf workflow show <name-or-hash>` | Show workflow definition |
|
||||
| `uwf workflow list` | List registered workflows |
|
||||
|
||||
### CAS
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf cas get <hash> [--timestamp]` | Read a CAS node |
|
||||
| `uwf cas put <type-hash> <data>` | Store a node, print hash |
|
||||
| `uwf cas put-text <text>` | Store plain text, print hash |
|
||||
| `uwf cas has <hash>` | Check existence |
|
||||
| `uwf cas refs <hash>` | List direct references |
|
||||
| `uwf cas walk <hash>` | Recursive traversal |
|
||||
| `uwf cas reindex` | Rebuild type index |
|
||||
| `uwf cas schema list` | List registered schemas |
|
||||
| `uwf cas schema get <hash>` | Show a schema |
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
uwf setup
|
||||
uwf setup --provider openai --base-url https://api.openai.com/v1 \
|
||||
--api-key sk-... --model gpt-4o --agent hermes
|
||||
```
|
||||
|
||||
Config: `~/.uncaged/workflow/config.yaml`. API keys: `~/.uncaged/workflow/.env`.
|
||||
|
||||
### Skill
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf skill cli` | Print markdown reference of all uwf commands (for agent skills) |
|
||||
|
||||
### Log
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf log list` | List log files with sizes |
|
||||
| `uwf log show [--thread <id>] [--process <pid>] [--date YYYY-MM-DD]` | Show filtered log entries |
|
||||
| `uwf log clean [--before YYYY-MM-DD]` | Delete old log files |
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── cli.ts Commander entrypoint, command registration
|
||||
├── format.ts JSON/YAML output formatting
|
||||
├── store.ts CAS store + registry initialization
|
||||
├── validate.ts Workflow YAML validation
|
||||
├── schemas.ts CLI-local schema registration
|
||||
└── commands/
|
||||
├── thread.ts Thread lifecycle and step execution
|
||||
├── workflow.ts Workflow registry (put/show/list)
|
||||
├── cas.ts CAS inspection and schema ops
|
||||
├── setup.ts Interactive/non-interactive setup
|
||||
├── skill.ts Built-in skill references
|
||||
└── log.ts Process debug log management
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.uncaged/workflow/config.yaml` | Providers, models, default agent |
|
||||
| `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) |
|
||||
| `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash |
|
||||
| `~/.uncaged/workflow/threads.yaml` | Active thread head pointers |
|
||||
| `~/.uncaged/workflow/cas/` | Content-addressed node storage |
|
||||
@@ -62,9 +62,9 @@ const olderEntry = JSON.stringify({
|
||||
|
||||
async function writeLogFiles(): Promise<void> {
|
||||
const logsDir = join(storageRoot, "logs");
|
||||
await writeFile(join(logsDir, "2026-05-20.jsonl"), [entry1, entry2, entry3].join("\n") + "\n");
|
||||
await writeFile(join(logsDir, "2026-05-19.jsonl"), oldEntry + "\n");
|
||||
await writeFile(join(logsDir, "2026-05-18.jsonl"), olderEntry + "\n");
|
||||
await writeFile(join(logsDir, "2026-05-20.jsonl"), `${[entry1, entry2, entry3].join("\n")}\n`);
|
||||
await writeFile(join(logsDir, "2026-05-19.jsonl"), `${oldEntry}\n`);
|
||||
await writeFile(join(logsDir, "2026-05-18.jsonl"), `${olderEntry}\n`);
|
||||
}
|
||||
|
||||
describe("cmdLogList", () => {
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
_discoverAgents,
|
||||
_isBackspace,
|
||||
_isTerminator,
|
||||
_parseWhichOutput,
|
||||
_printModelMenu,
|
||||
_printProviderMenu,
|
||||
_printValidationResult,
|
||||
_resolveModelChoice,
|
||||
_resolveProviderChoice,
|
||||
_searchPathDirs,
|
||||
} from "../commands/setup.js";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 1a. _searchPathDirs
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_searchPathDirs", () => {
|
||||
test("returns empty array for empty PATH", async () => {
|
||||
const result = await _searchPathDirs("");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("finds uwf-hermes in a single dir", async () => {
|
||||
const dir = mkdirSync(join(tmpdir(), `uwf-test-${Date.now()}`), { recursive: true }) as
|
||||
| string
|
||||
| undefined;
|
||||
const actualDir = dir ?? join(tmpdir(), `uwf-test-${Date.now()}`);
|
||||
mkdirSync(actualDir, { recursive: true });
|
||||
const filePath = join(actualDir, "uwf-hermes");
|
||||
writeFileSync(filePath, "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(actualDir);
|
||||
expect(result).toContain("uwf-hermes");
|
||||
});
|
||||
|
||||
test("skips non-uwf- prefixed binaries", async () => {
|
||||
const dir = join(tmpdir(), `uwf-test-${Date.now()}-2`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(dir);
|
||||
expect(result).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("skips entry named exactly 'uwf'", async () => {
|
||||
const dir = join(tmpdir(), `uwf-test-${Date.now()}-3`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "uwf"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(dir);
|
||||
expect(result).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("skips non-executable files", async () => {
|
||||
const dir = join(tmpdir(), `uwf-test-${Date.now()}-4`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "uwf-foo"), "#!/bin/sh\n", { mode: 0o644 });
|
||||
const result = await _searchPathDirs(dir);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("deduplicates across PATH dirs", async () => {
|
||||
const dir1 = join(tmpdir(), `uwf-test-${Date.now()}-5a`);
|
||||
const dir2 = join(tmpdir(), `uwf-test-${Date.now()}-5b`);
|
||||
mkdirSync(dir1, { recursive: true });
|
||||
mkdirSync(dir2, { recursive: true });
|
||||
writeFileSync(join(dir1, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir2, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(`${dir1}:${dir2}`);
|
||||
expect(result).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("returns sorted array", async () => {
|
||||
const dir = join(tmpdir(), `uwf-test-${Date.now()}-6`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "uwf-zoo"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir, "uwf-alpha"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir, "uwf-mid"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(dir);
|
||||
expect(result).toEqual(["uwf-alpha", "uwf-mid", "uwf-zoo"]);
|
||||
});
|
||||
|
||||
test("skips inaccessible/nonexistent directories silently", async () => {
|
||||
const result = await _searchPathDirs("/nonexistent-dir-xyz-abc-12345");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 1b. _parseWhichOutput
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_parseWhichOutput", () => {
|
||||
test("returns empty array for empty string", () => {
|
||||
expect(_parseWhichOutput("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("parses single path", () => {
|
||||
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes")).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("parses multiple paths", () => {
|
||||
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes\n/usr/bin/uwf-claude-code")).toEqual([
|
||||
"uwf-claude-code",
|
||||
"uwf-hermes",
|
||||
]);
|
||||
});
|
||||
|
||||
test("deduplicates identical basenames from different dirs", () => {
|
||||
expect(_parseWhichOutput("/a/uwf-hermes\n/b/uwf-hermes")).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("skips blank lines", () => {
|
||||
expect(_parseWhichOutput("/a/uwf-hermes\n\n/b/uwf-cursor")).toEqual([
|
||||
"uwf-cursor",
|
||||
"uwf-hermes",
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips entry named exactly 'uwf'", () => {
|
||||
expect(_parseWhichOutput("/usr/bin/uwf")).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips basenames not starting with uwf-", () => {
|
||||
expect(_parseWhichOutput("/usr/bin/node")).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns sorted array", () => {
|
||||
expect(_parseWhichOutput("/a/uwf-zoo\n/a/uwf-alpha")).toEqual(["uwf-alpha", "uwf-zoo"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 2a. _isTerminator
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_isTerminator", () => {
|
||||
test("\\n is a terminator", () => {
|
||||
expect(_isTerminator("\n")).toBe(true);
|
||||
});
|
||||
test("\\r is a terminator", () => {
|
||||
expect(_isTerminator("\r")).toBe(true);
|
||||
});
|
||||
test("\\u0004 (EOT) is a terminator", () => {
|
||||
expect(_isTerminator("")).toBe(true);
|
||||
});
|
||||
test("regular char is not a terminator", () => {
|
||||
expect(_isTerminator("a")).toBe(false);
|
||||
});
|
||||
test("empty string is not a terminator", () => {
|
||||
expect(_isTerminator("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 2b. _isBackspace
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_isBackspace", () => {
|
||||
test("\\u007F is a backspace", () => {
|
||||
expect(_isBackspace("")).toBe(true);
|
||||
});
|
||||
test("\\b is a backspace", () => {
|
||||
expect(_isBackspace("\b")).toBe(true);
|
||||
});
|
||||
test("regular char is not a backspace", () => {
|
||||
expect(_isBackspace("x")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3a. _printProviderMenu
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_printProviderMenu", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const providers = [
|
||||
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||
] as const;
|
||||
|
||||
test("prints correct number of lines (one per provider + custom)", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printProviderMenu(providers);
|
||||
// 2 providers + 1 custom = 3 lines
|
||||
expect(lines.length).toBe(3);
|
||||
});
|
||||
|
||||
test("custom option number = providers.length + 1", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printProviderMenu(providers);
|
||||
const lastLine = lines[lines.length - 1] ?? "";
|
||||
expect(lastLine).toMatch(/3\)/);
|
||||
});
|
||||
|
||||
test("each provider line contains its label and baseUrl", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printProviderMenu(providers);
|
||||
expect(lines[0]).toContain("OpenAI");
|
||||
expect(lines[0]).toContain("https://api.openai.com/v1");
|
||||
expect(lines[1]).toContain("xAI");
|
||||
expect(lines[1]).toContain("https://api.x.ai/v1");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3b. _resolveProviderChoice
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_resolveProviderChoice", () => {
|
||||
const providers = [
|
||||
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||
] as const;
|
||||
|
||||
test("valid index 1 returns first provider", () => {
|
||||
const result = _resolveProviderChoice("1", providers);
|
||||
expect(result).toEqual({ providerName: "openai", baseUrl: "https://api.openai.com/v1" });
|
||||
});
|
||||
|
||||
test("valid index N (last preset) returns last provider", () => {
|
||||
const result = _resolveProviderChoice("3", providers);
|
||||
expect(result).toEqual({ providerName: "deepseek", baseUrl: "https://api.deepseek.com/v1" });
|
||||
});
|
||||
|
||||
test("index providers.length+1 (custom) returns null", () => {
|
||||
const result = _resolveProviderChoice("4", providers);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("non-numeric string returns null", () => {
|
||||
expect(_resolveProviderChoice("abc", providers)).toBeNull();
|
||||
});
|
||||
|
||||
test("0 returns null (out of range)", () => {
|
||||
expect(_resolveProviderChoice("0", providers)).toBeNull();
|
||||
});
|
||||
|
||||
test("N+2 returns null (out of range)", () => {
|
||||
expect(_resolveProviderChoice("5", providers)).toBeNull();
|
||||
});
|
||||
|
||||
test("negative number returns null", () => {
|
||||
expect(_resolveProviderChoice("-1", providers)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3c. _resolveModelChoice
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_resolveModelChoice", () => {
|
||||
test("numeric input within range returns model at that index", () => {
|
||||
expect(_resolveModelChoice("2", ["a", "b", "c"])).toBe("b");
|
||||
});
|
||||
|
||||
test("numeric input out of range returns input as-is", () => {
|
||||
expect(_resolveModelChoice("5", ["a"])).toBe("5");
|
||||
});
|
||||
|
||||
test("non-numeric input returns input as-is", () => {
|
||||
expect(_resolveModelChoice("gpt-4o", ["a", "b"])).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
test("numeric input 1 returns first model", () => {
|
||||
expect(_resolveModelChoice("1", ["alpha", "beta"])).toBe("alpha");
|
||||
});
|
||||
|
||||
test("empty models list with numeric input returns input as-is", () => {
|
||||
expect(_resolveModelChoice("1", [])).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3d. _printModelMenu
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_printModelMenu", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("prints all models — each model name appears in output", () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
output.push(msg);
|
||||
});
|
||||
const models = ["model-a", "model-b", "model-c"];
|
||||
_printModelMenu(models, 100);
|
||||
const combined = output.join("\n");
|
||||
for (const m of models) {
|
||||
expect(combined).toContain(m);
|
||||
}
|
||||
});
|
||||
|
||||
test("single column when termCols is very small", () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
output.push(msg);
|
||||
});
|
||||
_printModelMenu(["a", "b", "c"], 1);
|
||||
// Each model on its own row → 3 lines
|
||||
expect(output.length).toBe(3);
|
||||
});
|
||||
|
||||
test("wide terminal fits multiple columns", () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
output.push(msg);
|
||||
});
|
||||
const models = Array.from({ length: 6 }, (_, i) => `m${i}`);
|
||||
_printModelMenu(models, 200);
|
||||
// With wide terminal and short names, should fit in fewer than 6 rows
|
||||
expect(output.length).toBeLessThan(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3e. _printValidationResult
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_printValidationResult", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("ok=true prints success message containing '✓'", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printValidationResult({ ok: true, error: null });
|
||||
expect(lines.join("\n")).toContain("✓");
|
||||
});
|
||||
|
||||
test("ok=false prints warning message containing '⚠'", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||
expect(lines.join("\n")).toContain("⚠");
|
||||
});
|
||||
|
||||
test("ok=false includes the error string in output", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||
expect(lines.join("\n")).toContain("HTTP 401");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Regression
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_discoverAgents regression", () => {
|
||||
test("returns an array (may be empty) — never throws", async () => {
|
||||
const result = await _discoverAgents();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,683 @@
|
||||
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, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import type { UwfStore } from "../store.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 makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
return { storageRoot, store, schemas };
|
||||
}
|
||||
|
||||
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||
await bootstrap(store);
|
||||
const [turn, detail] = await Promise.all([
|
||||
putSchema(store, TURN_SCHEMA),
|
||||
putSchema(store, DETAIL_SCHEMA),
|
||||
]);
|
||||
return { turn, detail };
|
||||
}
|
||||
|
||||
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── thread read XML tag isolation ─────────────────────────────────────────────
|
||||
|
||||
describe("thread read XML tag isolation", () => {
|
||||
test("scenario 1: wraps output in XML tags instead of heading", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Planner",
|
||||
goal: "You are a planning agent. Your task is to...",
|
||||
capabilities: [],
|
||||
procedure: "Plan the work.",
|
||||
output: "Summarize the plan.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Fix issue #459",
|
||||
});
|
||||
|
||||
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:
|
||||
"---\nstatus: ready\nplan: CMWGHQKT58RY4\n---\n\n# Analysis Complete\n## Issue Summary\nThe issue requires XML tag isolation.",
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||
sessionId: "sx",
|
||||
model: "mx",
|
||||
duration: 500,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
|
||||
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "planner",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-claude-code",
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000001" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
|
||||
// Should wrap output in XML tags
|
||||
expect(markdown).toContain("<output>");
|
||||
expect(markdown).toContain("</output>");
|
||||
|
||||
// Should not have ### Content heading
|
||||
expect(markdown).not.toContain("### Content");
|
||||
|
||||
// Should preserve markdown headings inside output tags
|
||||
expect(markdown).toContain("# Analysis Complete");
|
||||
expect(markdown).toContain("## Issue Summary");
|
||||
});
|
||||
|
||||
test("scenario 2: wraps prompt in XML tags", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Planner",
|
||||
goal: "You are a planning agent. Your task is to analyze and plan.",
|
||||
capabilities: [],
|
||||
procedure: "Plan the work.",
|
||||
output: "Summarize the plan.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Fix issue",
|
||||
});
|
||||
|
||||
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: "---\nstatus: ready\n---\n\nContent here...",
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||
sessionId: "sx",
|
||||
model: "mx",
|
||||
duration: 500,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
|
||||
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "planner",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-claude-code",
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000002" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
|
||||
// Should wrap prompt in XML tags
|
||||
expect(markdown).toContain("<prompt>");
|
||||
expect(markdown).toContain("</prompt>");
|
||||
expect(markdown).toContain("You are a planning agent. Your task is to analyze and plan.");
|
||||
|
||||
// Should not have ### Prompt heading
|
||||
expect(markdown).not.toContain("### Prompt");
|
||||
|
||||
// Should wrap output in XML tags
|
||||
expect(markdown).toContain("<output>");
|
||||
expect(markdown).toContain("</output>");
|
||||
});
|
||||
|
||||
test("scenario 3: same role repeated does not show prompt twice", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
writer: {
|
||||
description: "Writer",
|
||||
goal: "You are a writer agent.",
|
||||
capabilities: [],
|
||||
procedure: "Write content.",
|
||||
output: "Summarize writing.",
|
||||
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 step1 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "writer",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: step1 as CasRef,
|
||||
role: "writer",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: step2 });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
|
||||
// Should only show prompt tags once
|
||||
const promptCount = (markdown.match(/<prompt>/g) ?? []).length;
|
||||
expect(promptCount).toBe(1);
|
||||
});
|
||||
|
||||
test("scenario 4: step with no detail shows no output tags", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do work.",
|
||||
output: "Summarize work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Do stuff",
|
||||
});
|
||||
|
||||
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: "worker",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
|
||||
// Should not have output tags
|
||||
expect(markdown).not.toContain("<output>");
|
||||
expect(markdown).not.toContain("</output>");
|
||||
|
||||
// Step header should still be displayed
|
||||
expect(markdown).toContain("## Step 1: worker");
|
||||
|
||||
// Prompt should still be shown
|
||||
expect(markdown).toContain("<prompt>");
|
||||
});
|
||||
|
||||
test("scenario 5: empty content shows no output tags", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Do stuff",
|
||||
});
|
||||
|
||||
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// A detail ref that doesn't exist → extractLastAssistantContent returns null
|
||||
const missingDetailRef = "missingdetail0" as CasRef;
|
||||
|
||||
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: missingDetailRef,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
|
||||
// Should not have output tags
|
||||
expect(markdown).not.toContain("<output>");
|
||||
expect(markdown).not.toContain("</output>");
|
||||
});
|
||||
|
||||
test("scenario 6: thread read with --start flag shows task section", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
roleA: {
|
||||
description: "Role A",
|
||||
goal: "Goal for roleA",
|
||||
capabilities: [],
|
||||
procedure: "Do stuff.",
|
||||
output: "Output.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Initial prompt",
|
||||
});
|
||||
|
||||
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: "roleA",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000006" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true);
|
||||
|
||||
// Should include task section
|
||||
expect(markdown).toContain("# Thread");
|
||||
expect(markdown).toContain("## Task");
|
||||
expect(markdown).toContain("Initial prompt");
|
||||
|
||||
// Prompts should use XML tags
|
||||
expect(markdown).toContain("<prompt>");
|
||||
});
|
||||
|
||||
test("scenario 7: thread read with --before parameter", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
roleA: {
|
||||
description: "Role A",
|
||||
goal: "Goal for roleA",
|
||||
capabilities: [],
|
||||
procedure: "Do stuff.",
|
||||
output: "Output.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
roleB: {
|
||||
description: "Role B",
|
||||
goal: "Goal for roleB",
|
||||
capabilities: [],
|
||||
procedure: "Do stuff.",
|
||||
output: "Output.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
roleC: {
|
||||
description: "Role C",
|
||||
goal: "Goal for roleC",
|
||||
capabilities: [],
|
||||
procedure: "Do stuff.",
|
||||
output: "Output.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Initial prompt",
|
||||
});
|
||||
|
||||
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const step1 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "roleA",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: step1 as CasRef,
|
||||
role: "roleB",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: step2 as CasRef,
|
||||
role: "roleC",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000007" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: step3 });
|
||||
|
||||
const markdown = await cmdThreadRead(
|
||||
tmpDir,
|
||||
threadId,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
step2 as CasRef,
|
||||
false,
|
||||
);
|
||||
|
||||
// Should only show roleA
|
||||
expect(markdown).toContain("roleA");
|
||||
expect(markdown).not.toContain("roleB");
|
||||
expect(markdown).not.toContain("roleC");
|
||||
|
||||
// Should use XML tags
|
||||
expect(markdown).toContain("<prompt>");
|
||||
});
|
||||
|
||||
test("scenario 9: special characters in content are preserved", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
writer: {
|
||||
description: "Writer",
|
||||
goal: "You are a writer.",
|
||||
capabilities: [],
|
||||
procedure: "Write content.",
|
||||
output: "Summarize.",
|
||||
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 turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Content with <special> & characters > like <this>",
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||
sessionId: "sx",
|
||||
model: "mx",
|
||||
duration: 500,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
|
||||
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "writer",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000008" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
|
||||
// Special characters should be preserved as-is
|
||||
expect(markdown).toContain("Content with <special> & characters > like <this>");
|
||||
});
|
||||
|
||||
test("scenario 10: quota limit with XML tags", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
roleA: {
|
||||
description: "Role A",
|
||||
goal: "Goal for roleA",
|
||||
capabilities: [],
|
||||
procedure: "Do stuff.",
|
||||
output: "Output.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Initial prompt",
|
||||
});
|
||||
|
||||
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const steps: CasRef[] = [];
|
||||
let prev: CasRef | null = null;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const step = (await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev,
|
||||
role: "roleA",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
})) as CasRef;
|
||||
steps.push(step);
|
||||
prev = step;
|
||||
}
|
||||
|
||||
const threadId = "01JTEST0000000000000009" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: steps[steps.length - 1]! });
|
||||
|
||||
// Use very small quota
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
|
||||
|
||||
// Should have skip hint
|
||||
expect(markdown).toContain("earlier step");
|
||||
|
||||
// Should have XML tags for displayed steps
|
||||
if (markdown.includes("<prompt>")) {
|
||||
expect(markdown).toContain("</prompt>");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -198,10 +198,10 @@ describe("extractLastAssistantContent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── cmdThreadRead: ### Content section ───────────────────────────────────────
|
||||
// ── cmdThreadRead: <output> section ──────────────────────────────────────────
|
||||
|
||||
describe("cmdThreadRead ### Content section", () => {
|
||||
test("includes ### Content before ### Output when detail has assistant turns", async () => {
|
||||
describe("cmdThreadRead <output> section", () => {
|
||||
test("includes <output> tags when detail has assistant turns", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||
|
||||
@@ -264,17 +264,13 @@ describe("cmdThreadRead ### Content section", () => {
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
|
||||
expect(markdown).toContain("### Content");
|
||||
expect(markdown).toContain("<output>");
|
||||
expect(markdown).toContain("</output>");
|
||||
expect(markdown).toContain("The assistant response text");
|
||||
|
||||
const contentIdx = markdown.indexOf("### Content");
|
||||
const outputIdx = markdown.indexOf("### Output");
|
||||
expect(contentIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(outputIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(contentIdx).toBeLessThan(outputIdx);
|
||||
expect(markdown).not.toContain("### Content");
|
||||
});
|
||||
|
||||
test("omits ### Content when detail has no matching assistant turns", async () => {
|
||||
test("omits <output> tags when detail has no matching assistant turns", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
@@ -313,8 +309,9 @@ describe("cmdThreadRead ### Content section", () => {
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
|
||||
expect(markdown).not.toContain("<output>");
|
||||
expect(markdown).not.toContain("</output>");
|
||||
expect(markdown).not.toContain("### Content");
|
||||
expect(markdown).toContain("### Output");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -387,8 +384,266 @@ describe("cmdThreadStepDetails", () => {
|
||||
content: "done",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── cmdThreadRead: <prompt> deduplication ────────────────────────────────────
|
||||
|
||||
describe("cmdThreadRead <prompt> deduplication", () => {
|
||||
async function makeThreadWithRoles(uwf: UwfStore, roles: string[]): Promise<string> {
|
||||
const roleMap: Record<string, unknown> = {};
|
||||
for (const r of [...new Set(roles)]) {
|
||||
roleMap[r] = {
|
||||
description: r,
|
||||
goal: `Goal for ${r}`,
|
||||
capabilities: [],
|
||||
procedure: "Do stuff.",
|
||||
output: "Output.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
};
|
||||
}
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "dedup-wf",
|
||||
description: "desc",
|
||||
roles: roleMap,
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Start",
|
||||
});
|
||||
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
let prev: string | null = null;
|
||||
let stepHash = "";
|
||||
for (const role of roles) {
|
||||
stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: prev as CasRef | null,
|
||||
role,
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
prev = stepHash;
|
||||
}
|
||||
return stepHash;
|
||||
}
|
||||
|
||||
test("same consecutive role shows <prompt> once", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const headHash = await makeThreadWithRoles(uwf, ["writer", "writer"]);
|
||||
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
const count = (markdown.match(/<prompt>/g) ?? []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("different consecutive roles each show <prompt>", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const headHash = await makeThreadWithRoles(uwf, ["planner", "coder"]);
|
||||
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
const count = (markdown.match(/<prompt>/g) ?? []).length;
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("non-consecutive same role shows <prompt> twice", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const headHash = await makeThreadWithRoles(uwf, ["roleA", "roleB", "roleA"]);
|
||||
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
const count = (markdown.match(/<prompt>/g) ?? []).length;
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cmdThreadRead: showStart / before / quota ─────────────────────────────────
|
||||
|
||||
describe("cmdThreadRead start section / before / quota", () => {
|
||||
async function makeSimpleThread(
|
||||
uwf: UwfStore,
|
||||
roles: string[],
|
||||
): Promise<{ startHash: CasRef; stepHashes: CasRef[] }> {
|
||||
const uniqueRoles = [...new Set(roles)];
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "simple-wf",
|
||||
description: "desc",
|
||||
roles: Object.fromEntries(
|
||||
uniqueRoles.map((r) => [
|
||||
r,
|
||||
{
|
||||
description: r,
|
||||
goal: `Goal for ${r}`,
|
||||
capabilities: [],
|
||||
procedure: "Do stuff.",
|
||||
output: "Output.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
]),
|
||||
),
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
const startHash = (await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Initial prompt",
|
||||
})) as CasRef;
|
||||
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const stepHashes: CasRef[] = [];
|
||||
let prev: CasRef | null = null;
|
||||
for (const role of roles) {
|
||||
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev,
|
||||
role,
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
})) as CasRef;
|
||||
stepHashes.push(stepHash);
|
||||
prev = stepHash;
|
||||
}
|
||||
return { startHash, stepHashes };
|
||||
}
|
||||
|
||||
test("showStart=true includes # Thread header and ## Task section", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
|
||||
const threadId = "01JTEST0000000000000006" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true);
|
||||
expect(markdown).toContain("# Thread");
|
||||
expect(markdown).toContain("## Task");
|
||||
expect(markdown).toContain("Initial prompt");
|
||||
});
|
||||
|
||||
test("showStart=false with before=null still shows # Thread header (default behavior)", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
|
||||
const threadId = "01JTEST0000000000000007" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
|
||||
|
||||
// When before=null, the start section is always shown regardless of showStart
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
expect(markdown).toContain("# Thread");
|
||||
expect(markdown).toContain("## Task");
|
||||
});
|
||||
|
||||
test("before filter: only steps before the given hash appear", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA", "roleB", "roleC"]);
|
||||
const [_hashA, hashB, hashC] = stepHashes as [CasRef, CasRef, CasRef];
|
||||
const threadId = "01JTEST0000000000000008" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: hashC });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, hashB, false);
|
||||
expect(markdown).toContain("roleA");
|
||||
expect(markdown).not.toContain("roleB");
|
||||
expect(markdown).not.toContain("roleC");
|
||||
});
|
||||
|
||||
test("quota=1 limits output and includes skip hint", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA", "roleB", "roleC"]);
|
||||
const threadId = "01JTEST000000000000000A" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
|
||||
expect(markdown).toContain("earlier step");
|
||||
});
|
||||
|
||||
test("all steps fit in quota: no skip hint", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
|
||||
const threadId = "01JTEST000000000000000B" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[0]! });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
expect(markdown).not.toContain("earlier step");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tests that call process.exit must be last ─────────────────────────────────
|
||||
|
||||
describe("cmdThreadStepDetails (process.exit tests - must be last)", () => {
|
||||
test("throws when step hash does not exist", async () => {
|
||||
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("before with unknown hash rejects", async () => {
|
||||
const _uwf = await makeUwfStore(tmpDir);
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const uwfStore: UwfStore = { storageRoot: tmpDir, store, schemas };
|
||||
|
||||
const workflowHash = await uwfStore.store.put(uwfStore.schemas.workflow, {
|
||||
name: "wf2",
|
||||
description: "",
|
||||
roles: {
|
||||
roleA: {
|
||||
description: "r",
|
||||
goal: "g",
|
||||
capabilities: [],
|
||||
procedure: "p",
|
||||
output: "o",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
const startHash = await uwfStore.store.put(uwfStore.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "p",
|
||||
});
|
||||
const outputHash = await uwfStore.store.put(uwfStore.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
const stepHash = await uwfStore.store.put(uwfStore.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "roleA",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
await saveThreadsIndex(tmpDir, { ["01JTEST000000000000000C" as ThreadId]: stepHash as CasRef });
|
||||
|
||||
await expect(
|
||||
cmdThreadRead(
|
||||
tmpDir,
|
||||
"01JTEST000000000000000C" as ThreadId,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
"unknownhash0" as CasRef,
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { stringify } from "yaml";
|
||||
import { cmdThreadStart } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import type { UwfStore } from "../store.js";
|
||||
import { loadWorkflowRegistry, saveWorkflowRegistry } from "../store.js";
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
return { storageRoot, store, schemas };
|
||||
}
|
||||
|
||||
async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
||||
const payload: WorkflowPayload = {
|
||||
name,
|
||||
description: "Test workflow",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||
}
|
||||
|
||||
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
|
||||
const payload: WorkflowPayload = {
|
||||
name,
|
||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
const yaml = stringify(payload);
|
||||
return yaml;
|
||||
}
|
||||
|
||||
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
let storageRoot: string;
|
||||
let projectRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-wf-resolve-test-"));
|
||||
storageRoot = join(tmpDir, "storage");
|
||||
projectRoot = join(tmpDir, "project");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
await mkdir(projectRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Strategy 1: CAS Hash Resolution ───────────────────────────────────────────
|
||||
|
||||
describe("Strategy 1: CAS Hash Resolution", () => {
|
||||
test("should resolve valid 13-char Crockford Base32 hash", async () => {
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const hash = await storeWorkflow(uwf, "test-workflow");
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, hash, "test prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toBe(hash);
|
||||
expect(result.thread).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
|
||||
});
|
||||
|
||||
test("should fail on invalid hash format (non-Crockford characters)", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(
|
||||
cmdThreadStart(storageRoot, "123456789ABCD", "prompt", projectRoot),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should fail on valid-format hash not present in CAS", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const fakeHash = "0000000000000"; // valid format, doesn't exist
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, fakeHash, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should reject 40-char hex hash (legacy format not supported)", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const hexHash = "a".repeat(40);
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, hexHash, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Strategy 2: File Path Resolution ──────────────────────────────────────────
|
||||
|
||||
describe("Strategy 2: File Path Resolution", () => {
|
||||
test("should load workflow from absolute file path", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "test-workflow.yaml");
|
||||
await writeFile(yamlPath, await createWorkflowYaml("test-workflow"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).name).toBe("test-workflow");
|
||||
}
|
||||
});
|
||||
|
||||
test("should load workflow from relative file path", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = "test-workflow.yaml";
|
||||
await writeFile(join(projectRoot, yamlPath), await createWorkflowYaml("test-workflow"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should fail when file path does not exist", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(
|
||||
cmdThreadStart(storageRoot, "./nonexistent.yaml", "prompt", projectRoot),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should fail on invalid YAML syntax in file", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "bad-syntax.yaml");
|
||||
await writeFile(yamlPath, "invalid: yaml: : :");
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
|
||||
await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph");
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should enforce filename matches workflow name", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "solve-issue.yaml");
|
||||
await writeFile(yamlPath, await createWorkflowYaml("wrong-name"));
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Strategy 3: Local Discovery (Parent Traversal) ────────────────────────────
|
||||
|
||||
describe("Strategy 3: Local Discovery", () => {
|
||||
test("should find workflow in current directory .workflow/", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).name).toBe("solve-issue");
|
||||
}
|
||||
});
|
||||
|
||||
test("should find workflow in parent directory .workflow/", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
|
||||
|
||||
const subdir = join(projectRoot, "packages", "cli-workflow", "src");
|
||||
await mkdir(subdir, { recursive: true });
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", subdir);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should stop at filesystem root when traversing", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const deepPath = join(tmpDir, "deep", "path", "that", "does", "not", "have", "workflow");
|
||||
await mkdir(deepPath, { recursive: true });
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", deepPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should prefer .workflow/ over .workflows/ directory", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
const workflowsDir = join(projectRoot, ".workflows");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(workflowDir, "solve-issue.yaml"),
|
||||
await createWorkflowYaml("solve-issue", "1"),
|
||||
);
|
||||
await writeFile(
|
||||
join(workflowsDir, "solve-issue.yaml"),
|
||||
await createWorkflowYaml("solve-issue", "2"),
|
||||
);
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (1)");
|
||||
}
|
||||
});
|
||||
|
||||
test("should support .yml extension in local discovery", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(join(workflowDir, "solve-issue.yml"), await createWorkflowYaml("solve-issue"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Strategy 4: Global Registry Fallback ──────────────────────────────────────
|
||||
|
||||
describe("Strategy 4: Global Registry Resolution", () => {
|
||||
test("should resolve workflow from global registry when not found locally", async () => {
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const hash = await storeWorkflow(uwf, "deploy-pipeline");
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
registry["deploy-pipeline"] = hash;
|
||||
await saveWorkflowRegistry(storageRoot, registry);
|
||||
|
||||
const isolatedRoot = join(tmpDir, "isolated");
|
||||
await mkdir(isolatedRoot, { recursive: true });
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "deploy-pipeline", "prompt", isolatedRoot);
|
||||
|
||||
expect(result.workflow).toBe(hash);
|
||||
});
|
||||
|
||||
test("should fail when workflow not found in any strategy", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", tmpDir)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Strategy Priority Order ───────────────────────────────────────────────────
|
||||
|
||||
describe("Resolution Priority", () => {
|
||||
test("should use explicit file path over local discovery", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
// Setup: Create workflow in .workflow/ AND as explicit file
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(workflowDir, "solve-issue.yaml"),
|
||||
await createWorkflowYaml("solve-issue", "discovery"),
|
||||
);
|
||||
|
||||
const explicitPath = join(projectRoot, "custom-solve-issue.yaml");
|
||||
await writeFile(explicitPath, await createWorkflowYaml("custom-solve-issue", "explicit"));
|
||||
|
||||
// Execute with explicit path
|
||||
const result = await cmdThreadStart(storageRoot, explicitPath, "prompt", projectRoot);
|
||||
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (explicit)");
|
||||
}
|
||||
});
|
||||
|
||||
test("should use local discovery over global registry", async () => {
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
|
||||
// Setup: Register globally
|
||||
const globalHash = await storeWorkflow(uwf, "solve-issue");
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
registry["solve-issue"] = globalHash;
|
||||
await saveWorkflowRegistry(storageRoot, registry);
|
||||
|
||||
// Setup: Create local .workflow/
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
const localYaml = await createWorkflowYaml("solve-issue", "local");
|
||||
await writeFile(join(workflowDir, "solve-issue.yaml"), localYaml);
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
const uwf2 = await makeUwfStore(storageRoot);
|
||||
const node = uwf2.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (local)");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edge Cases ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("should treat '13-char-string.yaml' as file path, not CAS hash", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const fileName = "0123456789ABC.yaml"; // 13 chars + .yaml
|
||||
await writeFile(join(projectRoot, fileName), await createWorkflowYaml("0123456789ABC"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, fileName, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should handle workflow names containing slashes as file paths", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const filePath = "subdir/solve-issue.yaml";
|
||||
const fullPath = join(projectRoot, filePath);
|
||||
await mkdir(join(projectRoot, "subdir"), { recursive: true });
|
||||
await writeFile(fullPath, await createWorkflowYaml("solve-issue"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, filePath, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should handle absolute paths correctly", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const absPath = join(tmpDir, "abs-workflow.yaml");
|
||||
await writeFile(absPath, await createWorkflowYaml("abs-workflow"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, absPath, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should fail on empty workflow ID", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, "", "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should fail on whitespace-only workflow ID", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, " ", "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RunningThreadItem, ThreadId } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { RunningMarker } from "./types.js";
|
||||
|
||||
/**
|
||||
* Get the path to the running markers directory.
|
||||
*/
|
||||
export function getRunningDir(storageRoot: string): string {
|
||||
return join(storageRoot, "running");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a specific thread's marker file.
|
||||
*/
|
||||
export function getMarkerPath(storageRoot: string, threadId: ThreadId): string {
|
||||
return join(getRunningDir(storageRoot), `${threadId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a PID is still running.
|
||||
* Returns true if the process exists, false otherwise.
|
||||
*/
|
||||
export function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
// process.kill with signal 0 checks existence without killing
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
// ESRCH means process doesn't exist
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a marker file for a running thread.
|
||||
* Writes to a temp file in the same directory, then atomically renames.
|
||||
*/
|
||||
export async function createMarker(storageRoot: string, marker: RunningMarker): Promise<void> {
|
||||
const runningDir = getRunningDir(storageRoot);
|
||||
await mkdir(runningDir, { recursive: true });
|
||||
|
||||
const markerPath = getMarkerPath(storageRoot, marker.thread);
|
||||
const tempPath = join(runningDir, `.${marker.thread}-${process.pid}.tmp`);
|
||||
|
||||
const content = JSON.stringify(marker, null, 2);
|
||||
await writeFile(tempPath, content, "utf8");
|
||||
await rename(tempPath, markerPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a marker file for a thread.
|
||||
*/
|
||||
export async function deleteMarker(storageRoot: string, threadId: ThreadId): Promise<void> {
|
||||
const markerPath = getMarkerPath(storageRoot, threadId);
|
||||
try {
|
||||
await rm(markerPath);
|
||||
} catch {
|
||||
// Ignore errors if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a marker file. Returns null if file doesn't exist or is invalid.
|
||||
*/
|
||||
export async function readMarker(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
): Promise<RunningMarker | null> {
|
||||
const markerPath = getMarkerPath(storageRoot, threadId);
|
||||
try {
|
||||
const content = await readFile(markerPath, "utf8");
|
||||
const marker = JSON.parse(content) as RunningMarker;
|
||||
return marker;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all running threads, filtering out stale markers.
|
||||
*/
|
||||
export async function listRunningThreads(storageRoot: string): Promise<RunningThreadItem[]> {
|
||||
const runningDir = getRunningDir(storageRoot);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(runningDir);
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: RunningThreadItem[] = [];
|
||||
|
||||
for (const filename of files) {
|
||||
if (!filename.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const threadId = filename.slice(0, -5) as ThreadId;
|
||||
const marker = await readMarker(storageRoot, threadId);
|
||||
|
||||
if (marker === null) {
|
||||
// Invalid marker file
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isPidAlive(marker.pid)) {
|
||||
// Stale marker - process no longer exists
|
||||
await deleteMarker(storageRoot, threadId);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
thread: marker.thread,
|
||||
workflow: marker.workflow,
|
||||
pid: marker.pid,
|
||||
startedAt: marker.startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a thread is currently executing in the background.
|
||||
* Returns the marker if running, null otherwise.
|
||||
*/
|
||||
export async function isThreadRunning(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
): Promise<RunningMarker | null> {
|
||||
const marker = await readMarker(storageRoot, threadId);
|
||||
if (marker === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPidAlive(marker.pid)) {
|
||||
// Stale marker
|
||||
await deleteMarker(storageRoot, threadId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
createMarker,
|
||||
deleteMarker,
|
||||
getMarkerPath,
|
||||
getRunningDir,
|
||||
isPidAlive,
|
||||
isThreadRunning,
|
||||
listRunningThreads,
|
||||
readMarker,
|
||||
} from "./background.js";
|
||||
export type { RunningMarker } from "./types.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
|
||||
/** Marker file stored at ~/.uncaged/workflow/running/<thread-id>.json */
|
||||
export type RunningMarker = {
|
||||
thread: ThreadId;
|
||||
workflow: CasRef;
|
||||
pid: number;
|
||||
startedAt: number;
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
cmdThreadKill,
|
||||
cmdThreadList,
|
||||
cmdThreadRead,
|
||||
cmdThreadRunning,
|
||||
cmdThreadShow,
|
||||
cmdThreadStart,
|
||||
cmdThreadStep,
|
||||
@@ -114,19 +115,41 @@ thread
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("--agent <cmd>", "Override agent command")
|
||||
.option("-c, --count <number>", "Number of steps to run (default: 1)")
|
||||
.action((threadId: string, opts: { agent: string | undefined; count: string | undefined }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const agentOverride = opts.agent ?? null;
|
||||
const count = opts.count !== undefined ? Number(opts.count) : 1;
|
||||
const results = await cmdThreadStep(storageRoot, threadId, agentOverride, count);
|
||||
if (results.length === 1) {
|
||||
writeOutput(results[0]);
|
||||
} else {
|
||||
writeOutput(results);
|
||||
}
|
||||
});
|
||||
});
|
||||
.option("--background", "Run in background and return immediately")
|
||||
.option("--_background-worker", "Internal flag for background worker process", false)
|
||||
.action(
|
||||
(
|
||||
threadId: string,
|
||||
opts: {
|
||||
agent: string | undefined;
|
||||
count: string | undefined;
|
||||
background: boolean;
|
||||
_backgroundWorker: boolean;
|
||||
},
|
||||
) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const agentOverride = opts.agent ?? null;
|
||||
const count = opts.count !== undefined ? Number(opts.count) : 1;
|
||||
const background = opts.background ?? false;
|
||||
const backgroundWorker = opts._backgroundWorker ?? false;
|
||||
|
||||
const results = await cmdThreadStep(
|
||||
storageRoot,
|
||||
threadId,
|
||||
agentOverride,
|
||||
count,
|
||||
background,
|
||||
backgroundWorker,
|
||||
);
|
||||
if (results.length === 1) {
|
||||
writeOutput(results[0]);
|
||||
} else {
|
||||
writeOutput(results);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
thread
|
||||
.command("show")
|
||||
@@ -152,6 +175,17 @@ thread
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("running")
|
||||
.description("List threads currently executing in the background")
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadRunning(storageRoot);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("kill")
|
||||
.description("Terminate and archive a thread")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
@@ -137,72 +137,182 @@ function apiKeyEnvName(providerName: string): string {
|
||||
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Extracted helpers — _discoverAgents
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scans directories from a PATH string for uwf-* executables.
|
||||
*/
|
||||
export async function _searchPathDirs(pathEnv: string): Promise<string[]> {
|
||||
if (!pathEnv) return [];
|
||||
const dirs = pathEnv.split(":").filter((d) => d.length > 0);
|
||||
const agents = new Set<string>();
|
||||
for (const dir of dirs) {
|
||||
_scanDirForAgents(dir, agents);
|
||||
}
|
||||
return Array.from(agents).sort();
|
||||
}
|
||||
|
||||
function _scanDirForAgents(dir: string, agents: Set<string>): void {
|
||||
try {
|
||||
if (!existsSync(dir)) return;
|
||||
const entries = readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
|
||||
if (_isExecutableFile(join(dir, entry))) {
|
||||
agents.add(entry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip inaccessible directories
|
||||
}
|
||||
}
|
||||
|
||||
function _isExecutableFile(fullPath: string): boolean {
|
||||
try {
|
||||
const s = statSync(fullPath);
|
||||
return s.isFile() && (s.mode & 0o111) !== 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the stdout of `which -a` into sorted unique basenames.
|
||||
*/
|
||||
export function _parseWhichOutput(text: string): string[] {
|
||||
if (!text) return [];
|
||||
const agents = new Set<string>();
|
||||
for (const line of text.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const basename = line.split("/").pop() ?? "";
|
||||
if (basename.startsWith("uwf-") && basename !== "uwf") {
|
||||
agents.add(basename);
|
||||
}
|
||||
}
|
||||
return Array.from(agents).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover uwf-* agent binaries in PATH.
|
||||
* Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]).
|
||||
*/
|
||||
async function discoverAgents(): Promise<string[]> {
|
||||
export async function _discoverAgents(): Promise<string[]> {
|
||||
try {
|
||||
const agents = await _tryWhichDiscovery();
|
||||
if (agents !== null) return agents;
|
||||
return await _searchPathDirs(process.env.PATH ?? "");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function _tryWhichDiscovery(): Promise<string[] | null> {
|
||||
try {
|
||||
// Use which -a to find all uwf-* binaries in PATH
|
||||
const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const text = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
// Try alternative approach: search PATH directories manually
|
||||
const pathEnv = process.env.PATH || "";
|
||||
const pathDirs = pathEnv.split(":").filter((d) => d.length > 0);
|
||||
const agents = new Set<string>();
|
||||
|
||||
for (const dir of pathDirs) {
|
||||
try {
|
||||
if (!existsSync(dir)) continue;
|
||||
const { readdirSync, statSync } = await import("node:fs");
|
||||
const entries = readdirSync(dir);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
|
||||
const fullPath = join(dir, entry);
|
||||
try {
|
||||
const stat = statSync(fullPath);
|
||||
// Check if executable (owner, group, or other has execute bit)
|
||||
if (stat.isFile() && (stat.mode & 0o111) !== 0) {
|
||||
agents.add(entry);
|
||||
}
|
||||
} catch {
|
||||
// Skip if can't stat
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip inaccessible directories
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(agents).sort();
|
||||
}
|
||||
|
||||
// Parse which output - each line is a path to a binary
|
||||
const paths = text.trim().split("\n").filter((line) => line.length > 0);
|
||||
const agents = new Set<string>();
|
||||
|
||||
for (const path of paths) {
|
||||
const basename = path.split("/").pop();
|
||||
if (basename && basename.startsWith("uwf-") && basename !== "uwf") {
|
||||
agents.add(basename);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(agents).sort();
|
||||
if (proc.exitCode !== 0) return null;
|
||||
return _parseWhichOutput(text);
|
||||
} catch {
|
||||
// If all fails, return empty array
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Extracted helpers — onData closure (promptSecret)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true for newline, carriage return, or EOF (EOT). */
|
||||
export function _isTerminator(c: string): boolean {
|
||||
return c === "\n" || c === "\r" || c === "";
|
||||
}
|
||||
|
||||
/** Returns true for DEL or backspace. */
|
||||
export function _isBackspace(c: string): boolean {
|
||||
return c === "" || c === "\b";
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Extracted helpers — cmdSetupInteractive
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ProviderEntry = { name: string; label: string; baseUrl: string };
|
||||
|
||||
/** Prints the numbered provider list and custom option to stdout. */
|
||||
export function _printProviderMenu(providers: readonly ProviderEntry[]): void {
|
||||
const numWidth = String(providers.length + 1).length;
|
||||
for (let i = 0; i < providers.length; i++) {
|
||||
const p = providers[i];
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(providers.length + 1).padStart(numWidth);
|
||||
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||
}
|
||||
|
||||
/** Resolves a numeric choice string to a preset provider, or null for custom/invalid. */
|
||||
export function _resolveProviderChoice(
|
||||
choice: string,
|
||||
providers: readonly ProviderEntry[],
|
||||
): { providerName: string; baseUrl: string } | null {
|
||||
const n = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > providers.length) return null;
|
||||
const p = providers[n - 1];
|
||||
if (!p) return null;
|
||||
return { providerName: p.name, baseUrl: p.baseUrl };
|
||||
}
|
||||
|
||||
/** Resolves numeric index or literal model name to a model string. */
|
||||
export function _resolveModelChoice(input: string, models: string[]): string {
|
||||
const n = Number.parseInt(input, 10);
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= models.length) {
|
||||
return models[n - 1] ?? input;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Prints the multi-column model list to stdout. */
|
||||
export function _printModelMenu(models: string[], termCols: number): void {
|
||||
const nw = String(models.length).length;
|
||||
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const colWidth = nw + 2 + maxLen + 4;
|
||||
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||
const rows = Math.ceil(models.length / cols);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = "";
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = c * rows + r;
|
||||
if (idx >= models.length) break;
|
||||
const num = String(idx + 1).padStart(nw);
|
||||
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||
line += ` ${num}) ${name} `;
|
||||
}
|
||||
console.log(line.trimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
type ValidationResult = { ok: boolean; error: string | null };
|
||||
|
||||
/** Prints the model validation result to stdout. */
|
||||
export function _printValidationResult(validation: ValidationResult): void {
|
||||
if (validation.ok) {
|
||||
console.log("✓ Model verified — connection successful.\n");
|
||||
} else {
|
||||
console.log(`\n⚠ Warning: Could not reach model — ${validation.error}`);
|
||||
console.log(
|
||||
" Config saved, but you may want to try a different model or check your API key.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||
*/
|
||||
@@ -278,6 +388,46 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
type SecretState = {
|
||||
buf: string;
|
||||
rawWasSet: boolean;
|
||||
resolve: (value: string) => void;
|
||||
onData: (chunk: string) => void;
|
||||
};
|
||||
|
||||
function _handleSecretTerminator(state: SecretState): void {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", state.onData);
|
||||
process.stdout.write("\n");
|
||||
state.resolve(state.buf.trim());
|
||||
}
|
||||
|
||||
function _handleSecretBackspace(state: SecretState): void {
|
||||
if (state.buf.length > 0) {
|
||||
state.buf = state.buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
}
|
||||
|
||||
function _handleSecretChar(c: string, state: SecretState): boolean {
|
||||
if (_isTerminator(c)) {
|
||||
_handleSecretTerminator(state);
|
||||
return true;
|
||||
}
|
||||
if (_isBackspace(c)) {
|
||||
_handleSecretBackspace(state);
|
||||
return false;
|
||||
}
|
||||
if (c === "") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
|
||||
process.exit(130);
|
||||
}
|
||||
state.buf += c;
|
||||
process.stdout.write("*");
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
@@ -289,33 +439,13 @@ async function promptSecret(label: string): Promise<string> {
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
let buf = "";
|
||||
const onData = (chunk: string) => {
|
||||
const state: SecretState = { buf: "", rawWasSet, resolve, onData: () => {} };
|
||||
state.onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (c === "\n" || c === "\r" || c === "\u0004") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stdout.write("\n");
|
||||
resolve(buf.trim());
|
||||
return;
|
||||
}
|
||||
if (c === "\u007F" || c === "\b") {
|
||||
if (buf.length > 0) {
|
||||
buf = buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.exit(130);
|
||||
}
|
||||
buf += c;
|
||||
process.stdout.write("*");
|
||||
if (_handleSecretChar(c, state)) return;
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
process.stdin.on("data", state.onData);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -341,6 +471,56 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function _promptProviderSelection(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
): Promise<{ providerName: string; baseUrl: string }> {
|
||||
console.log("Select a provider:\n");
|
||||
_printProviderMenu(PRESET_PROVIDERS);
|
||||
|
||||
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||
throw new Error(`Invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
const preset = _resolveProviderChoice(choice, PRESET_PROVIDERS);
|
||||
if (preset) {
|
||||
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||
if (selected) {
|
||||
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
const providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||
if (!providerName) throw new Error("Provider name required");
|
||||
const baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||
if (!baseUrl) throw new Error("Base URL required");
|
||||
return { providerName, baseUrl };
|
||||
}
|
||||
|
||||
async function _promptModelSelection(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
): Promise<string> {
|
||||
console.log("\nFetching available models...");
|
||||
const models = await fetchModels(baseUrl, apiKey);
|
||||
|
||||
if (models.length === 0) {
|
||||
console.log("Could not fetch models. Enter model name manually.");
|
||||
const model = (await rl.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||
if (!model) throw new Error("Model required");
|
||||
return model;
|
||||
}
|
||||
console.log(`\nAvailable models (${models.length}):\n`);
|
||||
_printModelMenu(models, process.stdout.columns || 100);
|
||||
console.log(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = (await rl.question(`Default model [1-${models.length}]: `)).trim();
|
||||
if (!modelInput) throw new Error("Model required");
|
||||
return _resolveModelChoice(modelInput, models);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive setup — prompts user for provider, API key, model.
|
||||
*/
|
||||
@@ -350,39 +530,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
try {
|
||||
console.log("Configure LLM provider for uwf workflow agents.\n");
|
||||
|
||||
// 1. Provider selection
|
||||
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
|
||||
console.log("Select a provider:\n");
|
||||
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
|
||||
const p = PRESET_PROVIDERS[i];
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
|
||||
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||
|
||||
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||
throw new Error(`Invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
let providerName: string;
|
||||
let baseUrl: string;
|
||||
|
||||
if (choiceNum <= PRESET_PROVIDERS.length) {
|
||||
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||
if (!selected) throw new Error("Invalid selection");
|
||||
providerName = selected.name;
|
||||
baseUrl = selected.baseUrl;
|
||||
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
} else {
|
||||
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||
if (!providerName) throw new Error("Provider name required");
|
||||
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||
if (!baseUrl) throw new Error("Base URL required");
|
||||
}
|
||||
const { providerName, baseUrl } = await _promptProviderSelection(rl);
|
||||
|
||||
// 2. API key
|
||||
rl.close();
|
||||
@@ -391,47 +539,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
|
||||
// 3. Model selection
|
||||
const rl2 = createInterface({ input, output });
|
||||
console.log("\nFetching available models...");
|
||||
const models = await fetchModels(baseUrl, apiKey);
|
||||
|
||||
let model: string;
|
||||
if (models.length > 0) {
|
||||
console.log(`\nAvailable models (${models.length}):\n`);
|
||||
const nw = String(models.length).length;
|
||||
// Multi-column layout
|
||||
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const colWidth = nw + 2 + maxLen + 4; // " N) name "
|
||||
const termCols = process.stdout.columns || 100;
|
||||
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||
const rows = Math.ceil(models.length / cols);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = "";
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = c * rows + r;
|
||||
if (idx >= models.length) break;
|
||||
const num = String(idx + 1).padStart(nw);
|
||||
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||
line += ` ${num}) ${name} `;
|
||||
}
|
||||
console.log(line.trimEnd());
|
||||
}
|
||||
console.log(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
|
||||
if (!modelInput) throw new Error("Model required");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
model = models[modelNum - 1] ?? modelInput;
|
||||
} else {
|
||||
model = modelInput;
|
||||
}
|
||||
} else {
|
||||
console.log("Could not fetch models. Enter model name manually.");
|
||||
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||
if (!model) throw new Error("Model required");
|
||||
}
|
||||
|
||||
const model = await _promptModelSelection(rl2, baseUrl, apiKey);
|
||||
rl2.close();
|
||||
|
||||
console.log(` → ${providerName}/${model}\n`);
|
||||
|
||||
const setupResult = await cmdSetup({
|
||||
@@ -444,17 +553,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
|
||||
// Show validation result
|
||||
if (setupResult.validation && typeof setupResult.validation === "object") {
|
||||
const v = setupResult.validation as { ok: boolean; error?: string };
|
||||
if (v.ok) {
|
||||
console.log("✓ Model verified — connection successful.\n");
|
||||
} else {
|
||||
console.log(`\n⚠ Warning: Could not reach model — ${v.error}`);
|
||||
console.log(
|
||||
" Config saved, but you may want to try a different model or check your API key.\n",
|
||||
);
|
||||
}
|
||||
_printValidationResult(setupResult.validation as ValidationResult);
|
||||
}
|
||||
|
||||
console.log("Setup complete! Get started:\n");
|
||||
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
||||
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModeratorContext,
|
||||
RunningThreadsOutput,
|
||||
StartEntry,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
@@ -26,16 +28,19 @@ import type {
|
||||
import { createProcessLogger, generateUlid, type ProcessLogger } from "@uncaged/workflow-util";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import {
|
||||
createMarker,
|
||||
deleteMarker,
|
||||
isThreadRunning,
|
||||
listRunningThreads,
|
||||
} from "../background/index.js";
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
discoverProjectWorkflows,
|
||||
findThreadInHistory,
|
||||
loadThreadHistory,
|
||||
loadThreadsIndex,
|
||||
loadWorkflowRegistry,
|
||||
resolveProjectWorkflowFile,
|
||||
resolveWorkflowHash,
|
||||
saveThreadsIndex,
|
||||
type ThreadHistoryLine,
|
||||
@@ -53,6 +58,7 @@ const PL_AGENT_SPAWN = "R5J2W8N4";
|
||||
const PL_AGENT_DONE = "C6P9E3H7";
|
||||
const PL_THREAD_ARCHIVED = "F4D8Q2K5";
|
||||
const PL_STEP_ERROR = "B8T5N1V6";
|
||||
const PL_BACKGROUND_START = "X7Q4W9M2";
|
||||
|
||||
function failStep(plog: ProcessLogger, message: string): never {
|
||||
plog.log(PL_STEP_ERROR, message, null);
|
||||
@@ -82,6 +88,83 @@ function fail(message: string): never {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a file path (contains path separators or has .yaml/.yml extension).
|
||||
*/
|
||||
function isFilePath(input: string): boolean {
|
||||
return (
|
||||
input.includes("/") || input.includes("\\") || input.endsWith(".yaml") || input.endsWith(".yml")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workflow file exists at the given path.
|
||||
*/
|
||||
async function workflowFileExists(dir: string, name: string, ext: string): Promise<string | null> {
|
||||
const candidate = resolvePath(dir, `${name}${ext}`);
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a workflow file in a given directory (checks both .workflow/ and .workflows/).
|
||||
*/
|
||||
async function findWorkflowInDir(dir: string, name: string): Promise<string | null> {
|
||||
// Check .workflow/ directory first (preferred)
|
||||
for (const ext of [".yaml", ".yml"]) {
|
||||
const result = await workflowFileExists(resolvePath(dir, ".workflow"), name, ext);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Check .workflows/ directory as fallback (legacy)
|
||||
for (const ext of [".yaml", ".yml"]) {
|
||||
const result = await workflowFileExists(resolvePath(dir, ".workflows"), name, ext);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse parent directories looking for `.workflow/<name>.yaml` or `.workflow/<name>.yml`.
|
||||
* Returns the absolute path if found, otherwise null.
|
||||
* Stops at filesystem root or .git directory.
|
||||
*/
|
||||
async function findWorkflowInParents(startDir: string, name: string): Promise<string | null> {
|
||||
let currentDir = resolvePath(startDir);
|
||||
const root = resolvePath("/");
|
||||
|
||||
while (true) {
|
||||
const found = await findWorkflowInDir(currentDir, name);
|
||||
if (found !== null) {
|
||||
return found;
|
||||
}
|
||||
|
||||
// Stop at filesystem root
|
||||
if (currentDir === root) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Move to parent directory
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
// Reached filesystem root
|
||||
break;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise<CasRef> {
|
||||
let text: string;
|
||||
try {
|
||||
@@ -123,18 +206,41 @@ async function resolveWorkflowCasRef(
|
||||
workflowId: string,
|
||||
projectRoot: string,
|
||||
): Promise<CasRef> {
|
||||
// Project-local resolution: check .workflows/<workflowId>.yaml first
|
||||
const localEntries = await discoverProjectWorkflows(projectRoot);
|
||||
const localFile = resolveProjectWorkflowFile(localEntries, workflowId);
|
||||
if (localFile !== null) {
|
||||
return materializeLocalWorkflow(uwf, localFile);
|
||||
// Validate input
|
||||
const trimmed = workflowId.trim();
|
||||
if (trimmed === "") {
|
||||
fail("workflow ID cannot be empty");
|
||||
}
|
||||
|
||||
// Global registry fallback
|
||||
// Strategy 1: Direct CAS hash
|
||||
if (isCasRef(trimmed)) {
|
||||
const node = uwf.store.get(trimmed);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found: ${trimmed}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.workflow) {
|
||||
fail(`node ${trimmed} is not a Workflow (type ${node.type})`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Strategy 2: Explicit file path (relative or absolute)
|
||||
if (isFilePath(trimmed)) {
|
||||
const absolutePath = isAbsolute(trimmed) ? trimmed : resolvePath(projectRoot, trimmed);
|
||||
return materializeLocalWorkflow(uwf, absolutePath);
|
||||
}
|
||||
|
||||
// Strategy 3: Local discovery (parent directory traversal)
|
||||
const localPath = await findWorkflowInParents(projectRoot, trimmed);
|
||||
if (localPath !== null) {
|
||||
return materializeLocalWorkflow(uwf, localPath);
|
||||
}
|
||||
|
||||
// Strategy 4: Global registry fallback
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
const hash = resolveWorkflowHash(registry, workflowId);
|
||||
const hash = resolveWorkflowHash(registry, trimmed);
|
||||
if (!isCasRef(hash)) {
|
||||
fail(`workflow not found: ${workflowId}`);
|
||||
fail(`workflow not found: ${trimmed}`);
|
||||
}
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null) {
|
||||
@@ -222,6 +328,7 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
thread: threadId,
|
||||
head: activeHead,
|
||||
done: false,
|
||||
background: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,6 +339,7 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
thread: threadId,
|
||||
head: hist.head,
|
||||
done: true,
|
||||
background: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -363,49 +471,68 @@ function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unkno
|
||||
return expandValue(store, schema, node.payload, seen);
|
||||
}
|
||||
|
||||
function expandCasRefField(store: CasStore, value: unknown, visited: Set<string>): unknown {
|
||||
if (typeof value === "string") {
|
||||
return expandDeep(store, value as CasRef, visited);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function expandAnyOfField(
|
||||
store: CasStore,
|
||||
schema: JSONSchema,
|
||||
value: unknown,
|
||||
visited: Set<string>,
|
||||
): unknown {
|
||||
if (!Array.isArray(schema.anyOf)) return value;
|
||||
for (const sub of schema.anyOf as JSONSchema[]) {
|
||||
if (sub.format === "cas_ref" && typeof value === "string") {
|
||||
return expandDeep(store, value as CasRef, visited);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function expandArrayField(
|
||||
store: CasStore,
|
||||
schema: JSONSchema,
|
||||
value: unknown,
|
||||
visited: Set<string>,
|
||||
): unknown {
|
||||
if (!schema.items || !Array.isArray(value)) return value;
|
||||
const itemSchema = schema.items as JSONSchema;
|
||||
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
|
||||
}
|
||||
|
||||
function expandObjectField(
|
||||
store: CasStore,
|
||||
schema: JSONSchema,
|
||||
value: unknown,
|
||||
visited: Set<string>,
|
||||
): unknown {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || !schema.properties) {
|
||||
return value;
|
||||
}
|
||||
const props = schema.properties as Record<string, JSONSchema>;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const propSchema = props[key];
|
||||
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function expandValue(
|
||||
store: CasStore,
|
||||
schema: JSONSchema,
|
||||
value: unknown,
|
||||
visited: Set<string>,
|
||||
): unknown {
|
||||
// If this field is a cas_ref, expand it
|
||||
if (schema.format === "cas_ref") {
|
||||
if (typeof value === "string") {
|
||||
return expandDeep(store, value as CasRef, visited);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// anyOf (nullable refs)
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
for (const sub of schema.anyOf as JSONSchema[]) {
|
||||
if (sub.format === "cas_ref" && typeof value === "string") {
|
||||
return expandDeep(store, value as CasRef, visited);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Array of cas_ref items
|
||||
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
||||
const itemSchema = schema.items as JSONSchema;
|
||||
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
|
||||
}
|
||||
|
||||
// Object with properties
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && schema.properties) {
|
||||
const props = schema.properties as Record<string, JSONSchema>;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const propSchema = props[key];
|
||||
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
if (schema.format === "cas_ref") return expandCasRefField(store, value, visited);
|
||||
if (Array.isArray(schema.anyOf)) return expandAnyOfField(store, schema, value, visited);
|
||||
if (schema.type === "array") return expandArrayField(store, schema, value, visited);
|
||||
return expandObjectField(store, schema, value, visited);
|
||||
}
|
||||
|
||||
function collectOrderedSteps(
|
||||
@@ -440,7 +567,7 @@ function collectOrderedSteps(
|
||||
}
|
||||
|
||||
function formatYaml(value: unknown): string {
|
||||
return stringify(value).trimEnd();
|
||||
return stringify(value, { aliasDuplicateObjects: false }).trimEnd();
|
||||
}
|
||||
|
||||
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
|
||||
@@ -489,6 +616,85 @@ export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): s
|
||||
return null;
|
||||
}
|
||||
|
||||
function sliceBeforeHash(
|
||||
candidates: OrderedStepItem[],
|
||||
before: CasRef,
|
||||
threadId: ThreadId,
|
||||
): OrderedStepItem[] {
|
||||
const idx = candidates.findIndex((s) => s.hash === before);
|
||||
if (idx === -1) {
|
||||
fail(`step ${before} not found in thread ${threadId}`);
|
||||
}
|
||||
return candidates.slice(0, idx);
|
||||
}
|
||||
|
||||
function selectByQuota(
|
||||
candidates: OrderedStepItem[],
|
||||
uwf: UwfStore,
|
||||
quota: number,
|
||||
): { selected: OrderedStepItem[]; skippedCount: number } {
|
||||
const selected: OrderedStepItem[] = [];
|
||||
let totalChars = 0;
|
||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||
const item = candidates[i];
|
||||
if (item === undefined) continue;
|
||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
||||
selected.unshift(item);
|
||||
totalChars += blockLen;
|
||||
if (totalChars > quota) break;
|
||||
}
|
||||
return { selected, skippedCount: candidates.length - selected.length };
|
||||
}
|
||||
|
||||
function formatStepHeader(stepNum: number, item: OrderedStepItem): string {
|
||||
const ts = new Date(item.timestamp)
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d+Z$/, "");
|
||||
return [
|
||||
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatStepPrompt(
|
||||
roleDef: WorkflowPayload["roles"][string] | undefined,
|
||||
role: string,
|
||||
shownPromptRoles: Set<string>,
|
||||
): string {
|
||||
if (!roleDef || shownPromptRoles.has(role)) return "";
|
||||
shownPromptRoles.add(role);
|
||||
return ["", "", "<prompt>", roleDef.goal, "</prompt>"].join("\n");
|
||||
}
|
||||
|
||||
function formatStepContent(uwf: UwfStore, item: OrderedStepItem): string {
|
||||
if (!item.payload.detail) return "";
|
||||
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
||||
if (content === null) return "";
|
||||
return ["", "", "<output>", content, "</output>"].join("\n");
|
||||
}
|
||||
|
||||
function formatStartSection(options: {
|
||||
threadId: ThreadId;
|
||||
workflowName: string;
|
||||
workflowHash: CasRef;
|
||||
prompt: string;
|
||||
before: CasRef | null;
|
||||
showStart: boolean;
|
||||
}): string {
|
||||
if (options.before !== null && !options.showStart) return "";
|
||||
return [
|
||||
`# Thread \`${options.threadId}\``,
|
||||
"",
|
||||
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
|
||||
"",
|
||||
"## Task",
|
||||
"",
|
||||
options.prompt,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatThreadReadMarkdown(options: {
|
||||
threadId: ThreadId;
|
||||
workflowName: string;
|
||||
@@ -501,50 +707,16 @@ function formatThreadReadMarkdown(options: {
|
||||
before: CasRef | null;
|
||||
showStart: boolean;
|
||||
}): string {
|
||||
const { ordered, uwf, workflow, quota, before, showStart } = options;
|
||||
const { ordered, uwf, workflow, quota, before } = options;
|
||||
|
||||
// Determine which steps to consider
|
||||
let candidates = ordered;
|
||||
if (before !== null) {
|
||||
const idx = candidates.findIndex((s) => s.hash === before);
|
||||
if (idx === -1) {
|
||||
fail(`step ${before} not found in thread ${options.threadId}`);
|
||||
}
|
||||
candidates = candidates.slice(0, idx);
|
||||
}
|
||||
const candidates = before !== null ? sliceBeforeHash(ordered, before, options.threadId) : ordered;
|
||||
const { selected, skippedCount } = selectByQuota(candidates, uwf, quota);
|
||||
|
||||
// Walk backward from newest, accumulating chars until quota exceeded
|
||||
const selected: OrderedStepItem[] = [];
|
||||
let totalChars = 0;
|
||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||
const item = candidates[i];
|
||||
if (item === undefined) continue;
|
||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
||||
selected.unshift(item);
|
||||
totalChars += blockLen;
|
||||
if (totalChars > quota) break;
|
||||
}
|
||||
|
||||
const skippedCount = candidates.length - selected.length;
|
||||
const parts: string[] = [];
|
||||
|
||||
// Start section
|
||||
if (before === null || showStart) {
|
||||
parts.push(
|
||||
[
|
||||
`# Thread \`${options.threadId}\``,
|
||||
"",
|
||||
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
|
||||
"",
|
||||
"## Task",
|
||||
"",
|
||||
options.prompt,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
const startSection = formatStartSection(options);
|
||||
if (startSection !== "") parts.push(startSection);
|
||||
|
||||
// Skip hint
|
||||
if (skippedCount > 0 && selected.length > 0) {
|
||||
const firstSelected = selected[0];
|
||||
if (firstSelected !== undefined) {
|
||||
@@ -554,34 +726,21 @@ function formatThreadReadMarkdown(options: {
|
||||
}
|
||||
}
|
||||
|
||||
// Step blocks
|
||||
const startIndex = candidates.length - selected.length;
|
||||
const shownPromptRoles = new Set<string>();
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const item = selected[i];
|
||||
if (item === undefined) continue;
|
||||
const stepNum = startIndex + i + 1;
|
||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||
const ts = new Date(item.timestamp)
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d+Z$/, "");
|
||||
const stepLines = [
|
||||
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||
];
|
||||
const roleDef = workflow.roles[item.payload.role];
|
||||
if (roleDef) {
|
||||
const prompt = roleDef.goal;
|
||||
stepLines.push("", "### Prompt", "", prompt);
|
||||
}
|
||||
if (item.payload.detail) {
|
||||
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
||||
if (content !== null) {
|
||||
stepLines.push("", "### Content", "", content);
|
||||
}
|
||||
}
|
||||
stepLines.push("", "### Output", "", "```yaml", outputYaml, "```");
|
||||
parts.push(stepLines.join("\n"));
|
||||
const stepBlock = [
|
||||
formatStepHeader(stepNum, item),
|
||||
formatStepPrompt(roleDef, item.payload.role, shownPromptRoles),
|
||||
formatStepContent(uwf, item),
|
||||
]
|
||||
.filter((s) => s !== "")
|
||||
.join("");
|
||||
parts.push(stepBlock);
|
||||
}
|
||||
|
||||
return parts.join("\n\n---\n\n");
|
||||
@@ -654,13 +813,11 @@ function spawnAgent(
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
): CasRef {
|
||||
const argv = [...agent.args, threadId, role];
|
||||
const env = { ...process.env, UWF_EDGE_PROMPT: edgePrompt };
|
||||
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync(agent.command, argv, {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
|
||||
});
|
||||
@@ -705,26 +862,60 @@ export async function cmdThreadStep(
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
count: number,
|
||||
background: boolean,
|
||||
backgroundWorker: boolean,
|
||||
): Promise<StepOutput[]> {
|
||||
if (count < 1 || !Number.isInteger(count)) {
|
||||
fail(`--count must be a positive integer, got: ${count}`);
|
||||
}
|
||||
|
||||
// Check if thread is already running in background (unless we ARE the background worker)
|
||||
if (!backgroundWorker) {
|
||||
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||
if (runningMarker !== null) {
|
||||
fail(`thread already executing in background (PID: ${runningMarker.pid})`);
|
||||
}
|
||||
}
|
||||
|
||||
const workflowHash = await resolveActiveThreadWorkflowHash(storageRoot, threadId);
|
||||
const plog = createProcessLogger({
|
||||
storageRoot,
|
||||
context: { thread: threadId, workflow: workflowHash },
|
||||
});
|
||||
|
||||
const results: StepOutput[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog);
|
||||
results.push(result);
|
||||
if (result.done) {
|
||||
break;
|
||||
if (background && !backgroundWorker) {
|
||||
// Spawn background process
|
||||
return cmdThreadStepBackground(storageRoot, threadId, agentOverride, count, plog, workflowHash);
|
||||
}
|
||||
|
||||
// If we're the background worker, create marker before execution
|
||||
let markerCreated = false;
|
||||
if (backgroundWorker) {
|
||||
await createMarker(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow: workflowHash,
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
markerCreated = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const results: StepOutput[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog);
|
||||
results.push(result);
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
// Cleanup marker if we created one
|
||||
if (markerCreated) {
|
||||
await deleteMarker(storageRoot, threadId);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function resolveActiveThreadWorkflowHash(
|
||||
@@ -741,6 +932,57 @@ async function resolveActiveThreadWorkflowHash(
|
||||
return chain.start.workflow;
|
||||
}
|
||||
|
||||
async function cmdThreadStepBackground(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
count: number,
|
||||
plog: ProcessLogger,
|
||||
workflowHash: CasRef,
|
||||
): Promise<StepOutput[]> {
|
||||
// Get current head to return to caller
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
if (headHash === undefined) {
|
||||
failStep(plog, `thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
// Spawn detached background process
|
||||
const scriptPath = process.argv[1];
|
||||
if (scriptPath === undefined) {
|
||||
failStep(plog, "unable to determine script path for background execution");
|
||||
}
|
||||
|
||||
const args = ["thread", "step", threadId, "--count", String(count)];
|
||||
|
||||
if (agentOverride !== null) {
|
||||
args.push("--agent", agentOverride);
|
||||
}
|
||||
|
||||
// Internal flag to signal the background worker to create/cleanup markers
|
||||
args.push("--_background-worker");
|
||||
|
||||
plog.log(PL_BACKGROUND_START, `spawning background process count=${count}`, null);
|
||||
|
||||
const child = spawn(scriptPath, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
// Return immediately with current state and background flag
|
||||
return [
|
||||
{
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
done: false,
|
||||
background: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function cmdThreadStepOnce(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
@@ -778,6 +1020,7 @@ async function cmdThreadStepOnce(
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
done: true,
|
||||
background: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -825,6 +1068,7 @@ async function cmdThreadStepOnce(
|
||||
thread: threadId,
|
||||
head: newHead,
|
||||
done,
|
||||
background: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -961,6 +1205,17 @@ export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Pr
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
// Check if thread is running in background and terminate it
|
||||
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||
if (runningMarker !== null) {
|
||||
try {
|
||||
process.kill(runningMarker.pid, "SIGTERM");
|
||||
} catch {
|
||||
// Process may have already exited, ignore error
|
||||
}
|
||||
await deleteMarker(storageRoot, threadId);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||
if (workflow === null) {
|
||||
@@ -980,3 +1235,8 @@ export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Pr
|
||||
|
||||
return { thread: threadId, archived: true };
|
||||
}
|
||||
|
||||
export async function cmdThreadRunning(storageRoot: string): Promise<RunningThreadsOutput> {
|
||||
const threads = await listRunningThreads(storageRoot);
|
||||
return { threads };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ export function formatOutput(data: unknown, format: OutputFormat): string {
|
||||
case "json":
|
||||
return JSON.stringify(data);
|
||||
case "yaml":
|
||||
return stringify(data).trimEnd();
|
||||
return stringify(data, { aliasDuplicateObjects: false }).trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# @uncaged/workflow-agent-builtin
|
||||
|
||||
`uwf-builtin` agent — built-in LLM agent with file read/write and shell tools.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 3 agent implementation. Runs an OpenAI-compatible chat completion loop with built-in tools (`read_file`, `write_file`, `run_command`). Uses the configured provider/model from `config.yaml`. Produces frontmatter markdown output and stores turn-by-turn session detail in CAS.
|
||||
|
||||
Useful when you want a self-contained agent without an external CLI like Hermes or Claude Code.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-util`
|
||||
|
||||
## Installation
|
||||
|
||||
Included as the `uwf-builtin` binary when you install `@uncaged/workflow-agent-builtin`:
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/workflow-agent-builtin
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Invoked by `uwf thread step`:
|
||||
|
||||
```bash
|
||||
uwf-builtin <thread-id> <role>
|
||||
```
|
||||
|
||||
Configure as default agent:
|
||||
|
||||
```bash
|
||||
uwf setup --agent builtin
|
||||
```
|
||||
|
||||
Override per step:
|
||||
|
||||
```bash
|
||||
uwf thread step <thread-id> --agent uwf-builtin
|
||||
```
|
||||
|
||||
Environment variables set by the engine:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Agent factory
|
||||
|
||||
```typescript
|
||||
function createBuiltinAgent(): () => Promise<void>
|
||||
function buildBuiltinMessages(ctx: AgentContext): ChatMessage[]
|
||||
```
|
||||
|
||||
### LLM loop
|
||||
|
||||
```typescript
|
||||
const BUILTIN_MAX_TURNS = 30;
|
||||
const BUILTIN_CONTINUE_MAX_TURNS = 5;
|
||||
|
||||
function runBuiltinLoop(/* options: RunBuiltinLoopOptions */): Promise<RunBuiltinLoopResult>
|
||||
function chatCompletionWithTools(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: ChatMessage[],
|
||||
tools: OpenAiToolDefinition[],
|
||||
): Promise<LlmAssistantResponse>
|
||||
```
|
||||
|
||||
`RunBuiltinLoopOptions` and `RunBuiltinLoopResult` are internal to `loop.ts` and not re-exported from `index.ts`.
|
||||
|
||||
### Tools
|
||||
|
||||
```typescript
|
||||
function getBuiltinTools(): readonly BuiltinTool[]
|
||||
function executeBuiltinTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<string>
|
||||
```
|
||||
|
||||
### Session and detail
|
||||
|
||||
```typescript
|
||||
function initSessionDir(storageRoot: string): Promise<void>
|
||||
function appendSessionTurn(storageRoot: string, sessionId: string, turn: BuiltinTurnPayload): Promise<void>
|
||||
function readSessionTurns(storageRoot: string, sessionId: string): Promise<BuiltinTurnPayload[]>
|
||||
function removeSession(storageRoot: string, sessionId: string): Promise<void>
|
||||
function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes>
|
||||
function storeBuiltinDetail(store: Store, payload: BuiltinDetailPayload): Promise<string>
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
type ChatMessage = /* system | user | assistant | tool */;
|
||||
type LlmAssistantResponse = { content: string | null; toolCalls: LlmToolCall[] | null };
|
||||
type LlmToolCall = { id: string; name: string; arguments: string };
|
||||
type BuiltinTool = { name: string; description: string; parameters: Record<string, unknown> };
|
||||
type ToolContext = { cwd: string; storageRoot: string };
|
||||
type BuiltinDetailPayload = { /* session turns, model, timestamps */ };
|
||||
type BuiltinLoopTurn = { /* single loop iteration record */ };
|
||||
type BuiltinToolCallRecord = { /* tool call audit */ };
|
||||
type BuiltinToolResultRecord = { /* tool result audit */ };
|
||||
type BuiltinTurnPayload = { /* persisted turn */ };
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── cli.ts Binary entrypoint
|
||||
├── agent.ts createBuiltinAgent
|
||||
├── loop.ts Multi-turn LLM + tool loop
|
||||
├── prompt.ts buildBuiltinMessages
|
||||
├── session.ts Session directory persistence
|
||||
├── detail.ts CAS detail node storage
|
||||
├── schemas.ts Builtin CAS schemas
|
||||
├── types.ts Detail and turn payload types
|
||||
├── llm/
|
||||
│ ├── index.ts
|
||||
│ ├── llm.ts chatCompletionWithTools
|
||||
│ └── types.ts ChatMessage, LlmToolCall, etc.
|
||||
└── tools/
|
||||
├── index.ts getBuiltinTools, executeBuiltinTool
|
||||
├── read-file.ts
|
||||
├── write-file.ts
|
||||
├── run-command.ts
|
||||
├── path.ts
|
||||
└── types.ts
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Requires a configured OpenAI-compatible provider and model in `~/.uncaged/workflow/config.yaml` (via `uwf setup`). API keys are loaded from `~/.uncaged/workflow/.env`.
|
||||
|
||||
Tools run with the current working directory as `ToolContext.cwd` (typically the directory where `uwf thread step` was invoked).
|
||||
@@ -0,0 +1,256 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
const mockChatCompletionWithTools = mock(async () => ({
|
||||
content: "---\nstatus: done\n---",
|
||||
toolCalls: [],
|
||||
}));
|
||||
const mockAppendSessionTurn = mock(async () => {});
|
||||
const mockExecuteBuiltinTool = mock(async () => "tool-result");
|
||||
|
||||
mock.module("../src/llm/index.js", () => ({
|
||||
chatCompletionWithTools: mockChatCompletionWithTools,
|
||||
}));
|
||||
mock.module("../src/session.js", () => ({
|
||||
appendSessionTurn: mockAppendSessionTurn,
|
||||
}));
|
||||
mock.module("../src/tools/index.js", () => ({
|
||||
builtinToolsToOpenAi: () => [],
|
||||
executeBuiltinTool: mockExecuteBuiltinTool,
|
||||
getBuiltinTools: () => [],
|
||||
}));
|
||||
|
||||
import {
|
||||
executeTurnTools,
|
||||
extractFinalText,
|
||||
runBuiltinLoop,
|
||||
shouldInjectDeadlineWarning,
|
||||
shouldNudge,
|
||||
shouldProcessToolCalls,
|
||||
} from "../src/loop.js";
|
||||
|
||||
const fakeProvider = {} as any;
|
||||
const fakeToolCtx = {} as any;
|
||||
|
||||
function makeOptions(overrides: Partial<Parameters<typeof runBuiltinLoop>[0]> = {}) {
|
||||
return {
|
||||
provider: fakeProvider,
|
||||
messages: [{ role: "system" as const, content: "sys" }],
|
||||
toolCtx: fakeToolCtx,
|
||||
maxTurns: 5,
|
||||
storageRoot: "/tmp",
|
||||
sessionId: "sess",
|
||||
noTools: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockChatCompletionWithTools.mockReset();
|
||||
mockAppendSessionTurn.mockReset();
|
||||
mockExecuteBuiltinTool.mockReset();
|
||||
});
|
||||
|
||||
describe("shouldNudge", () => {
|
||||
test("2.1 returns true when all conditions met", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "some text", turn: 0, maxTurns: 5 })).toBe(true);
|
||||
});
|
||||
test("2.2 returns false when noTools=true", () => {
|
||||
expect(shouldNudge({ noTools: true, text: "some text", turn: 0, maxTurns: 5 })).toBe(false);
|
||||
});
|
||||
test("2.3 returns false when text starts with ---", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("2.4 returns false on last turn", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "some text", turn: 4, maxTurns: 5 })).toBe(false);
|
||||
});
|
||||
test("2.5 returns true on second-to-last turn", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "some text", turn: 3, maxTurns: 5 })).toBe(true);
|
||||
});
|
||||
test("2.6 leading whitespace before --- suppresses nudge", () => {
|
||||
expect(shouldNudge({ noTools: false, text: " ---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeTurnTools", () => {
|
||||
test("4.1 executes each tool call and pushes tool result messages", async () => {
|
||||
mockExecuteBuiltinTool.mockResolvedValue("result");
|
||||
const messages: any[] = [];
|
||||
const calls = [
|
||||
{ id: "c1", name: "tool_a", arguments: "{}" },
|
||||
{ id: "c2", name: "tool_b", arguments: "{}" },
|
||||
];
|
||||
const count = await executeTurnTools(calls, fakeToolCtx, messages, "/tmp", "sess");
|
||||
expect(messages.length).toBe(2);
|
||||
expect(messages[0].role).toBe("tool");
|
||||
expect(messages[1].role).toBe("tool");
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
test("4.2 tool result content matches executeBuiltinTool return value", async () => {
|
||||
mockExecuteBuiltinTool.mockResolvedValue("result-A");
|
||||
const messages: any[] = [];
|
||||
await executeTurnTools(
|
||||
[{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||
fakeToolCtx,
|
||||
messages,
|
||||
"/tmp",
|
||||
"sess",
|
||||
);
|
||||
expect(messages[0].content).toBe("result-A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBuiltinLoop integration", () => {
|
||||
test("3.1 single text-only response returns finalText immediately", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({
|
||||
content: "---\nstatus: done\n---",
|
||||
toolCalls: [],
|
||||
});
|
||||
const result = await runBuiltinLoop(makeOptions());
|
||||
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||
expect(result.turnCount).toBe(1);
|
||||
});
|
||||
test("3.2 noTools=true suppresses tool calls", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({
|
||||
content: "ok",
|
||||
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||
});
|
||||
const result = await runBuiltinLoop(makeOptions({ noTools: true }));
|
||||
expect(result.finalText).toBe("ok");
|
||||
expect(result.turnCount).toBe(1);
|
||||
});
|
||||
test("3.3 tool call followed by text response", async () => {
|
||||
mockChatCompletionWithTools
|
||||
.mockResolvedValueOnce({
|
||||
content: null,
|
||||
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||
})
|
||||
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
|
||||
mockExecuteBuiltinTool.mockResolvedValue("file contents");
|
||||
const result = await runBuiltinLoop(makeOptions());
|
||||
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||
expect(result.turnCount).toBe(3);
|
||||
});
|
||||
test("3.4 nudge cycle inserts nudge message", async () => {
|
||||
mockChatCompletionWithTools
|
||||
.mockResolvedValueOnce({ content: "I am thinking", toolCalls: [] })
|
||||
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
|
||||
const result = await runBuiltinLoop(makeOptions());
|
||||
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||
const nudgeMsg = result.messages.find(
|
||||
(m) =>
|
||||
m.role === "user" && typeof m.content === "string" && m.content.includes("frontmatter"),
|
||||
);
|
||||
expect(nudgeMsg).toBeDefined();
|
||||
});
|
||||
test("3.5 maxTurns exhaustion falls back to last assistant content", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({ content: "still thinking", toolCalls: [] });
|
||||
const result = await runBuiltinLoop(makeOptions({ maxTurns: 3 }));
|
||||
expect(result.finalText).toBe("still thinking");
|
||||
});
|
||||
test("3.6 original messages array is not mutated", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({
|
||||
content: "---\nstatus: done\n---",
|
||||
toolCalls: [],
|
||||
});
|
||||
const original = [{ role: "system" as const, content: "sys" }];
|
||||
await runBuiltinLoop(makeOptions({ messages: original }));
|
||||
expect(original.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldInjectDeadlineWarning", () => {
|
||||
test("5.1 returns true when turn count reaches warning threshold and not yet warned", () => {
|
||||
expect(shouldInjectDeadlineWarning(7, 10, false, false)).toBe(true);
|
||||
});
|
||||
test("5.2 returns false when already warned", () => {
|
||||
expect(shouldInjectDeadlineWarning(7, 10, true, false)).toBe(false);
|
||||
});
|
||||
test("5.3 returns false when noTools is true", () => {
|
||||
expect(shouldInjectDeadlineWarning(7, 10, false, true)).toBe(false);
|
||||
});
|
||||
test("5.4 returns false when turns remaining > DEADLINE_WARNING_TURNS", () => {
|
||||
expect(shouldInjectDeadlineWarning(5, 10, false, false)).toBe(false);
|
||||
});
|
||||
test("5.5 returns true when exactly at warning threshold", () => {
|
||||
expect(shouldInjectDeadlineWarning(7, 10, false, false)).toBe(true);
|
||||
});
|
||||
test("5.6 returns false when turns remaining is 0", () => {
|
||||
expect(shouldInjectDeadlineWarning(10, 10, false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldProcessToolCalls", () => {
|
||||
test("6.1 returns true when toolCalls present and noTools=false", () => {
|
||||
expect(shouldProcessToolCalls([{ id: "x", name: "read", arguments: "{}" }], false)).toBe(true);
|
||||
});
|
||||
test("6.2 returns false when toolCalls is null", () => {
|
||||
expect(shouldProcessToolCalls(null, false)).toBe(false);
|
||||
});
|
||||
test("6.3 returns false when toolCalls is empty array", () => {
|
||||
expect(shouldProcessToolCalls([], false)).toBe(false);
|
||||
});
|
||||
test("6.4 returns false when noTools=true", () => {
|
||||
expect(shouldProcessToolCalls([{ id: "x", name: "read", arguments: "{}" }], true)).toBe(false);
|
||||
});
|
||||
test("6.5 returns true when multiple tool calls present", () => {
|
||||
expect(
|
||||
shouldProcessToolCalls(
|
||||
[
|
||||
{ id: "x1", name: "read", arguments: "{}" },
|
||||
{ id: "x2", name: "write", arguments: "{}" },
|
||||
],
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFinalText", () => {
|
||||
test("7.1 returns last assistant message content", () => {
|
||||
const messages = [
|
||||
{ role: "system" as const, content: "sys", tool_calls: null },
|
||||
{ role: "assistant" as const, content: "first", tool_calls: null },
|
||||
{ role: "assistant" as const, content: "last", tool_calls: null },
|
||||
];
|
||||
expect(extractFinalText(messages)).toBe("last");
|
||||
});
|
||||
test("7.2 returns empty string when no assistant messages", () => {
|
||||
expect(extractFinalText([{ role: "system" as const, content: "sys", tool_calls: null }])).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
test("7.3 skips assistant messages with null content", () => {
|
||||
const messages = [
|
||||
{ role: "assistant" as const, content: "first", tool_calls: null },
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: null,
|
||||
tool_calls: [{ id: "x", name: "t", arguments: "{}" }],
|
||||
},
|
||||
{ role: "assistant" as const, content: "second", tool_calls: null },
|
||||
];
|
||||
expect(extractFinalText(messages)).toBe("second");
|
||||
});
|
||||
test("7.4 skips assistant messages with empty content", () => {
|
||||
const messages = [
|
||||
{ role: "assistant" as const, content: "first", tool_calls: null },
|
||||
{ role: "assistant" as const, content: "", tool_calls: null },
|
||||
{ role: "user" as const, content: "nudge", tool_calls: null },
|
||||
];
|
||||
expect(extractFinalText(messages)).toBe("first");
|
||||
});
|
||||
test("7.5 handles empty messages array", () => {
|
||||
expect(extractFinalText([])).toBe("");
|
||||
});
|
||||
test("7.6 handles messages with only user and system roles", () => {
|
||||
const messages = [
|
||||
{ role: "system" as const, content: "sys", tool_calls: null },
|
||||
{ role: "user" as const, content: "query", tool_calls: null },
|
||||
];
|
||||
expect(extractFinalText(messages)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolvePath } from "../src/tools/path.js";
|
||||
import { resolve } from "node:path";
|
||||
import { resolvePath } from "../src/tools/path.js";
|
||||
|
||||
describe("resolvePath", () => {
|
||||
test("resolves relative paths against cwd", () => {
|
||||
|
||||
@@ -7,17 +7,44 @@ import {
|
||||
resolveModel,
|
||||
resolveStorageRoot,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { createLogger, generateUlid } from "@uncaged/workflow-util";
|
||||
|
||||
import { storeBuiltinDetail } from "./detail.js";
|
||||
import type { ChatMessage } from "./llm/index.js";
|
||||
import { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
|
||||
import { buildBuiltinMessages } from "./prompt.js";
|
||||
import type { BuiltinSessionState } from "./types.js";
|
||||
import { initSessionDir } from "./session.js";
|
||||
|
||||
const sessions = new Map<string, BuiltinSessionState>();
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function getSession(sessionId: string): BuiltinSessionState {
|
||||
const FRONTMATTER_FENCE = "---";
|
||||
|
||||
/**
|
||||
* Strip any text before the first `---` fence.
|
||||
* LLMs sometimes emit preamble text before the frontmatter block.
|
||||
*/
|
||||
function stripPreamble(text: string): string {
|
||||
if (text.startsWith(FRONTMATTER_FENCE)) {
|
||||
return text;
|
||||
}
|
||||
const idx = text.indexOf(`\n${FRONTMATTER_FENCE}\n`);
|
||||
if (idx !== -1) {
|
||||
log("6GWRP3QX", `stripped ${idx + 1} chars of preamble before frontmatter`);
|
||||
return text.slice(idx + 1);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
type SessionRecord = {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
startedAtMs: number;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const sessions = new Map<string, SessionRecord>();
|
||||
|
||||
function getSession(sessionId: string): SessionRecord {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session === undefined) {
|
||||
throw new Error(`builtin session not found: ${sessionId}`);
|
||||
@@ -36,31 +63,38 @@ async function runBuiltinWithMessages(
|
||||
storageRoot: string,
|
||||
provider: ReturnType<typeof resolveModel>,
|
||||
messages: ChatMessage[],
|
||||
session: BuiltinSessionState,
|
||||
session: SessionRecord,
|
||||
store: Store,
|
||||
maxTurns: number,
|
||||
noTools: boolean,
|
||||
): Promise<AgentRunResult> {
|
||||
const loopResult = await runBuiltinLoop({
|
||||
provider,
|
||||
messages,
|
||||
toolCtx: buildToolContext(storageRoot),
|
||||
maxTurns,
|
||||
existingTurns: session.turns,
|
||||
storageRoot,
|
||||
sessionId: session.sessionId,
|
||||
noTools,
|
||||
});
|
||||
|
||||
session.messages = loopResult.messages;
|
||||
session.turns = loopResult.turns;
|
||||
|
||||
const { detailHash, output } = await storeBuiltinDetail(
|
||||
if (loopResult.turnCount === 0) {
|
||||
log("5RWTK9NB", "no turns produced, returning empty output");
|
||||
return { output: "", detailHash: "", sessionId: session.sessionId };
|
||||
}
|
||||
|
||||
// Read jsonl → persist turns to CAS → store detail
|
||||
const { detailHash } = await storeBuiltinDetail(
|
||||
store,
|
||||
storageRoot,
|
||||
session.sessionId,
|
||||
session.model,
|
||||
session.startedAtMs,
|
||||
session.turns,
|
||||
);
|
||||
|
||||
const finalOutput = output !== "" ? output : loopResult.finalText;
|
||||
return { output: finalOutput, detailHash, sessionId: session.sessionId };
|
||||
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
|
||||
}
|
||||
|
||||
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
@@ -69,14 +103,14 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const provider = resolveModel(config, config.defaultModel);
|
||||
|
||||
const sessionId = generateUlid(Date.now());
|
||||
await initSessionDir(storageRoot);
|
||||
const messages = buildBuiltinMessages(ctx);
|
||||
|
||||
const session: BuiltinSessionState = {
|
||||
const session: SessionRecord = {
|
||||
sessionId,
|
||||
model: provider.model,
|
||||
startedAtMs: Date.now(),
|
||||
messages,
|
||||
turns: [],
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -87,6 +121,7 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
session,
|
||||
ctx.store,
|
||||
BUILTIN_MAX_TURNS,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,6 +144,7 @@ async function continueBuiltin(
|
||||
session,
|
||||
store,
|
||||
BUILTIN_CONTINUE_MAX_TURNS,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +1,15 @@
|
||||
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
|
||||
|
||||
import { BUILTIN_DETAIL_SCHEMA, BUILTIN_TURN_SCHEMA } from "./schemas.js";
|
||||
import type {
|
||||
BuiltinDetailPayload,
|
||||
BuiltinLoopTurn,
|
||||
BuiltinToolCall,
|
||||
BuiltinTurnPayload,
|
||||
BuiltinTurnRole,
|
||||
} from "./types.js";
|
||||
|
||||
function mapToolCalls(calls: NonNullable<BuiltinLoopTurn["toolCalls"]>): BuiltinToolCall[] {
|
||||
return calls.map((call) => ({
|
||||
name: call.name,
|
||||
args: call.args,
|
||||
}));
|
||||
}
|
||||
|
||||
function loopTurnToAssistantPayload(turn: BuiltinLoopTurn, index: number): BuiltinTurnPayload {
|
||||
return {
|
||||
index,
|
||||
role: "assistant",
|
||||
content: turn.assistantContent ?? "",
|
||||
toolCalls:
|
||||
turn.toolCalls !== null && turn.toolCalls.length > 0 ? mapToolCalls(turn.toolCalls) : null,
|
||||
reasoning: null,
|
||||
};
|
||||
}
|
||||
|
||||
function loopTurnToToolPayloads(turn: BuiltinLoopTurn, startIndex: number): BuiltinTurnPayload[] {
|
||||
if (turn.toolResults === null || turn.toolResults.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const payloads: BuiltinTurnPayload[] = [];
|
||||
let index = startIndex;
|
||||
for (const result of turn.toolResults) {
|
||||
payloads.push({
|
||||
index,
|
||||
role: "tool" as BuiltinTurnRole,
|
||||
content: result.content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
return payloads;
|
||||
}
|
||||
|
||||
/** Last assistant message with non-empty text. */
|
||||
export function extractFinalAssistantText(turns: BuiltinLoopTurn[]): string {
|
||||
for (let i = turns.length - 1; i >= 0; i--) {
|
||||
const turn = turns[i];
|
||||
if (turn === undefined) {
|
||||
continue;
|
||||
}
|
||||
const text = turn.assistantContent;
|
||||
if (text !== null && text.trim() !== "") {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
import { readSessionTurns } from "./session.js";
|
||||
import type { BuiltinDetailPayload } from "./types.js";
|
||||
|
||||
type BuiltinSchemaHashes = {
|
||||
turn: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
|
||||
export async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
|
||||
await bootstrap(store);
|
||||
const [turn, detail] = await Promise.all([
|
||||
putSchema(store, BUILTIN_TURN_SCHEMA),
|
||||
@@ -75,30 +18,22 @@ async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes
|
||||
return { turn, detail };
|
||||
}
|
||||
|
||||
/** Read session jsonl, persist each turn to CAS, return detail hash. */
|
||||
export async function storeBuiltinDetail(
|
||||
store: Store,
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
startedAtMs: number,
|
||||
turns: BuiltinLoopTurn[],
|
||||
nowMs: number = Date.now(),
|
||||
): Promise<{ detailHash: string; output: string }> {
|
||||
): Promise<{ detailHash: string; turnCount: number }> {
|
||||
const schemas = await registerBuiltinSchemas(store);
|
||||
const turns = await readSessionTurns(storageRoot, sessionId);
|
||||
|
||||
const turnHashes: string[] = [];
|
||||
let turnIndex = 0;
|
||||
|
||||
for (const loopTurn of turns) {
|
||||
const assistant = loopTurnToAssistantPayload(loopTurn, turnIndex);
|
||||
const assistantHash = await store.put(schemas.turn, assistant);
|
||||
turnHashes.push(assistantHash);
|
||||
turnIndex += 1;
|
||||
|
||||
const toolPayloads = loopTurnToToolPayloads(loopTurn, turnIndex);
|
||||
for (const toolPayload of toolPayloads) {
|
||||
const toolHash = await store.put(schemas.turn, toolPayload);
|
||||
turnHashes.push(toolHash);
|
||||
turnIndex += 1;
|
||||
}
|
||||
for (const turn of turns) {
|
||||
const hash = await store.put(schemas.turn, turn);
|
||||
turnHashes.push(hash);
|
||||
}
|
||||
|
||||
const duration = Math.max(0, nowMs - startedAtMs);
|
||||
@@ -110,6 +45,5 @@ export async function storeBuiltinDetail(
|
||||
turns: turnHashes,
|
||||
};
|
||||
const detailHash = await store.put(schemas.detail, detail);
|
||||
const output = extractFinalAssistantText(turns);
|
||||
return { detailHash, output };
|
||||
return { detailHash, turnCount: turnHashes.length };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
export { createBuiltinAgent } from "./agent.js";
|
||||
export { extractFinalAssistantText, storeBuiltinDetail } from "./detail.js";
|
||||
export { registerBuiltinSchemas, storeBuiltinDetail } from "./detail.js";
|
||||
export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js";
|
||||
export { chatCompletionWithTools } from "./llm/index.js";
|
||||
export { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
|
||||
export { buildBuiltinMessages } from "./prompt.js";
|
||||
export { appendSessionTurn, initSessionDir, readSessionTurns, removeSession } from "./session.js";
|
||||
export type { BuiltinTool, ToolContext } from "./tools/index.js";
|
||||
export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js";
|
||||
export type {
|
||||
BuiltinDetailPayload,
|
||||
BuiltinLoopTurn,
|
||||
BuiltinSessionState,
|
||||
BuiltinToolCallRecord,
|
||||
BuiltinToolResultRecord,
|
||||
BuiltinTurnPayload,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -96,8 +96,17 @@ function serializeMessage(message: ChatMessage): Record<string, unknown> {
|
||||
export async function chatCompletionWithTools(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: ChatMessage[],
|
||||
tools: OpenAiToolDefinition[],
|
||||
tools: OpenAiToolDefinition[] | null,
|
||||
): Promise<LlmAssistantResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: provider.model,
|
||||
messages: messages.map(serializeMessage),
|
||||
};
|
||||
if (tools !== null && tools.length > 0) {
|
||||
body.tools = tools;
|
||||
body.tool_choice = "auto";
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(chatUrl(provider.baseUrl), {
|
||||
@@ -106,12 +115,7 @@ export async function chatCompletionWithTools(
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages: messages.map(serializeMessage),
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { type ChatMessage, chatCompletionWithTools, type LlmToolCall } from "./llm/index.js";
|
||||
import {
|
||||
type ChatMessage,
|
||||
chatCompletionWithTools,
|
||||
type LlmToolCall,
|
||||
type OpenAiToolDefinition,
|
||||
} from "./llm/index.js";
|
||||
import { appendSessionTurn } from "./session.js";
|
||||
import {
|
||||
builtinToolsToOpenAi,
|
||||
executeBuiltinTool,
|
||||
getBuiltinTools,
|
||||
type ToolContext,
|
||||
} from "./tools/index.js";
|
||||
import type { BuiltinLoopTurn, BuiltinToolCallRecord, BuiltinToolResultRecord } from "./types.js";
|
||||
import type { BuiltinToolCall, BuiltinTurnPayload } from "./types.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
@@ -20,91 +26,278 @@ export type RunBuiltinLoopOptions = {
|
||||
messages: ChatMessage[];
|
||||
toolCtx: ToolContext;
|
||||
maxTurns: number;
|
||||
existingTurns: BuiltinLoopTurn[];
|
||||
storageRoot: string;
|
||||
sessionId: string;
|
||||
/** When true, do not provide tools — force LLM to emit text only. */
|
||||
noTools: boolean;
|
||||
};
|
||||
|
||||
export type RunBuiltinLoopResult = {
|
||||
finalText: string;
|
||||
messages: ChatMessage[];
|
||||
turns: BuiltinLoopTurn[];
|
||||
turnCount: number;
|
||||
};
|
||||
|
||||
function mapToolCalls(calls: LlmToolCall[]): BuiltinToolCallRecord[] {
|
||||
function mapToolCallsForPayload(calls: LlmToolCall[]): BuiltinToolCall[] {
|
||||
return calls.map((call) => ({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
args: call.arguments,
|
||||
}));
|
||||
}
|
||||
|
||||
async function appendTurn(
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
payload: BuiltinTurnPayload,
|
||||
): Promise<void> {
|
||||
await appendSessionTurn(storageRoot, sessionId, payload);
|
||||
}
|
||||
|
||||
export async function executeTurnTools(
|
||||
calls: Array<{ id: string; name: string; arguments: string }>,
|
||||
toolCtx: ToolContext,
|
||||
messages: ChatMessage[],
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
): Promise<number> {
|
||||
let turnCount = 0;
|
||||
for (const call of calls) {
|
||||
const result = await executeBuiltinTool(call.name, call.arguments, toolCtx);
|
||||
messages.push({ role: "tool", tool_call_id: call.id, content: result });
|
||||
await appendTurn(storageRoot, sessionId, {
|
||||
role: "tool",
|
||||
content: result,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
turnCount += 1;
|
||||
}
|
||||
return turnCount;
|
||||
}
|
||||
|
||||
export type ShouldNudgeOptions = {
|
||||
noTools: boolean;
|
||||
text: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
};
|
||||
|
||||
const MAX_NUDGES = 3;
|
||||
const DEADLINE_WARNING_TURNS = 3;
|
||||
|
||||
export function shouldInjectDeadlineWarning(
|
||||
turn: number,
|
||||
maxTurns: number,
|
||||
alreadyWarned: boolean,
|
||||
noTools: boolean,
|
||||
): boolean {
|
||||
const turnsRemaining = maxTurns - turn;
|
||||
return (
|
||||
!noTools && !alreadyWarned && turnsRemaining > 0 && turnsRemaining <= DEADLINE_WARNING_TURNS
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldProcessToolCalls(toolCalls: LlmToolCall[] | null, noTools: boolean): boolean {
|
||||
return !noTools && toolCalls !== null && toolCalls.length > 0;
|
||||
}
|
||||
|
||||
export function extractFinalText(messages: ChatMessage[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (
|
||||
msg !== undefined &&
|
||||
msg.role === "assistant" &&
|
||||
msg.content !== null &&
|
||||
msg.content.trim() !== ""
|
||||
) {
|
||||
return msg.content;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function injectDeadlineWarning(messages: ChatMessage[], turnsRemaining: number): void {
|
||||
log("4NRXW6KT", `${turnsRemaining} turns remaining, injecting deadline warning`);
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
`⚠️ You have ${turnsRemaining} turns remaining. ` +
|
||||
"Wrap up your work and output the YAML frontmatter starting with `---`. " +
|
||||
"If you cannot finish in time, output frontmatter with `status: failed` and describe what remains.",
|
||||
});
|
||||
}
|
||||
|
||||
type HandleTextOnlyTurnResult = {
|
||||
shouldBreak: boolean;
|
||||
finalText: string;
|
||||
turnCount: number;
|
||||
nudgeCount: number;
|
||||
turnAdjustment: number;
|
||||
};
|
||||
|
||||
async function handleTextOnlyTurn(
|
||||
text: string,
|
||||
messages: ChatMessage[],
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
noTools: boolean,
|
||||
turn: number,
|
||||
maxTurns: number,
|
||||
currentNudgeCount: number,
|
||||
): Promise<HandleTextOnlyTurnResult> {
|
||||
await appendTurn(storageRoot, sessionId, {
|
||||
role: "assistant",
|
||||
content: text,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const turnCount = 1;
|
||||
let nudgeCount = currentNudgeCount;
|
||||
let turnAdjustment = 0;
|
||||
|
||||
if (shouldNudge({ noTools, text, turn, maxTurns })) {
|
||||
nudgeCount += 1;
|
||||
log("7FXQM2KN", `text-only turn without frontmatter, nudge ${nudgeCount}/${MAX_NUDGES}`);
|
||||
const nudge =
|
||||
"You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " +
|
||||
"Either continue using tools to complete your work, or output your final response starting with `---`.";
|
||||
messages.push({ role: "user", content: nudge });
|
||||
// Nudge doesn't consume turn budget (up to MAX_NUDGES)
|
||||
if (nudgeCount <= MAX_NUDGES) {
|
||||
turnAdjustment = -1;
|
||||
}
|
||||
return { shouldBreak: false, finalText: "", turnCount, nudgeCount, turnAdjustment };
|
||||
}
|
||||
|
||||
return { shouldBreak: true, finalText: text, turnCount, nudgeCount, turnAdjustment };
|
||||
}
|
||||
|
||||
async function handleToolCallTurn(
|
||||
content: string,
|
||||
toolCalls: LlmToolCall[],
|
||||
messages: ChatMessage[],
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
toolCtx: ToolContext,
|
||||
): Promise<number> {
|
||||
await appendTurn(storageRoot, sessionId, {
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: mapToolCallsForPayload(toolCalls),
|
||||
reasoning: null,
|
||||
});
|
||||
let turnCount = 1;
|
||||
|
||||
// Execute tools
|
||||
turnCount += await executeTurnTools(toolCalls, toolCtx, messages, storageRoot, sessionId);
|
||||
|
||||
return turnCount;
|
||||
}
|
||||
|
||||
export function shouldNudge({ noTools, text, turn, maxTurns }: ShouldNudgeOptions): boolean {
|
||||
return !noTools && !text.trimStart().startsWith("---") && turn < maxTurns - 1;
|
||||
}
|
||||
|
||||
type ProcessLoopIterationResult = {
|
||||
shouldBreak: boolean;
|
||||
finalText: string;
|
||||
turnCount: number;
|
||||
nudgeCount: number;
|
||||
turnAdjustment: number;
|
||||
};
|
||||
|
||||
async function processLoopIteration(
|
||||
options: RunBuiltinLoopOptions,
|
||||
messages: ChatMessage[],
|
||||
openAiTools: OpenAiToolDefinition[],
|
||||
turn: number,
|
||||
nudgeCount: number,
|
||||
): Promise<ProcessLoopIterationResult> {
|
||||
const response = await chatCompletionWithTools(
|
||||
options.provider,
|
||||
messages,
|
||||
openAiTools.length > 0 ? openAiTools : null,
|
||||
);
|
||||
|
||||
// When noTools is set, ignore any tool_calls the LLM might still return
|
||||
const effectiveToolCalls = options.noTools ? null : (response.toolCalls ?? null);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: response.content,
|
||||
tool_calls: effectiveToolCalls,
|
||||
};
|
||||
messages.push(assistantMessage);
|
||||
|
||||
if (!shouldProcessToolCalls(effectiveToolCalls, options.noTools)) {
|
||||
const text = response.content ?? "";
|
||||
const result = await handleTextOnlyTurn(
|
||||
text,
|
||||
messages,
|
||||
options.storageRoot,
|
||||
options.sessionId,
|
||||
options.noTools,
|
||||
turn,
|
||||
options.maxTurns,
|
||||
nudgeCount,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// At this point, effectiveToolCalls is guaranteed to be non-null and non-empty
|
||||
const turnCount = await handleToolCallTurn(
|
||||
response.content ?? "",
|
||||
effectiveToolCalls as LlmToolCall[],
|
||||
messages,
|
||||
options.storageRoot,
|
||||
options.sessionId,
|
||||
options.toolCtx,
|
||||
);
|
||||
|
||||
return {
|
||||
shouldBreak: false,
|
||||
finalText: "",
|
||||
turnCount,
|
||||
nudgeCount,
|
||||
turnAdjustment: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
|
||||
export async function runBuiltinLoop(
|
||||
options: RunBuiltinLoopOptions,
|
||||
): Promise<RunBuiltinLoopResult> {
|
||||
const messages = [...options.messages];
|
||||
const turns = [...options.existingTurns];
|
||||
const openAiTools = builtinToolsToOpenAi(getBuiltinTools());
|
||||
const openAiTools = options.noTools ? [] : builtinToolsToOpenAi(getBuiltinTools());
|
||||
let finalText = "";
|
||||
let turnCount = 0;
|
||||
let nudgeCount = 0;
|
||||
let deadlineWarned = false;
|
||||
|
||||
for (let turn = 0; turn < options.maxTurns; turn++) {
|
||||
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
|
||||
const response = await chatCompletionWithTools(options.provider, messages, openAiTools);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: response.content,
|
||||
tool_calls: response.toolCalls,
|
||||
};
|
||||
messages.push(assistantMessage);
|
||||
// Warn agent when approaching turn limit
|
||||
if (shouldInjectDeadlineWarning(turn, options.maxTurns, deadlineWarned, options.noTools)) {
|
||||
deadlineWarned = true;
|
||||
const turnsRemaining = options.maxTurns - turn;
|
||||
injectDeadlineWarning(messages, turnsRemaining);
|
||||
}
|
||||
|
||||
if (response.toolCalls === null || response.toolCalls.length === 0) {
|
||||
finalText = response.content ?? "";
|
||||
turns.push({
|
||||
assistantContent: response.content,
|
||||
toolCalls: null,
|
||||
toolResults: null,
|
||||
});
|
||||
const result = await processLoopIteration(options, messages, openAiTools, turn, nudgeCount);
|
||||
turnCount += result.turnCount;
|
||||
nudgeCount = result.nudgeCount;
|
||||
turn += result.turnAdjustment;
|
||||
|
||||
if (result.shouldBreak) {
|
||||
finalText = result.finalText;
|
||||
break;
|
||||
}
|
||||
|
||||
const toolCallRecords = mapToolCalls(response.toolCalls);
|
||||
const toolResults: BuiltinToolResultRecord[] = [];
|
||||
|
||||
for (const call of response.toolCalls) {
|
||||
const result = await executeBuiltinTool(call.name, call.arguments, options.toolCtx);
|
||||
toolResults.push({
|
||||
toolCallId: call.id,
|
||||
name: call.name,
|
||||
content: result,
|
||||
});
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: result,
|
||||
});
|
||||
}
|
||||
|
||||
turns.push({
|
||||
assistantContent: response.content,
|
||||
toolCalls: toolCallRecords,
|
||||
toolResults,
|
||||
});
|
||||
}
|
||||
|
||||
if (finalText === "" && messages.length > 0) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (
|
||||
msg !== undefined &&
|
||||
msg.role === "assistant" &&
|
||||
msg.content !== null &&
|
||||
msg.content.trim() !== ""
|
||||
) {
|
||||
finalText = msg.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (finalText === "") {
|
||||
finalText = extractFinalText(messages);
|
||||
}
|
||||
|
||||
return { finalText, messages, turns };
|
||||
return { finalText, messages, turnCount };
|
||||
}
|
||||
|
||||
@@ -59,6 +59,22 @@ export function buildBuiltinMessages(ctx: AgentContext): ChatMessage[] {
|
||||
}
|
||||
systemParts.push(rolePrompt);
|
||||
|
||||
systemParts.push(
|
||||
"",
|
||||
"## Workflow",
|
||||
"",
|
||||
`Your working directory is: ${process.cwd()}`,
|
||||
"",
|
||||
"You have tools available (read_file, write_file, run_command). " +
|
||||
"Use them to complete your task — read files, run commands, make changes as needed. " +
|
||||
"Your task is described in the user message below — do NOT use uwf or workflow CLI commands to discover your task. " +
|
||||
"When you are done, output your final response with the YAML frontmatter block as specified above. " +
|
||||
"Do NOT output the frontmatter until you have completed all necessary work. " +
|
||||
"If you are running low on turns and cannot finish, output the frontmatter with `status: failed` and explain what remains in the body. " +
|
||||
"CRITICAL: Your final output MUST start with the `---` fence on the very first line — " +
|
||||
"no preamble text, no explanation before it. The parser requires `---` at position 0.",
|
||||
);
|
||||
|
||||
const messages: ChatMessage[] = [{ role: "system", content: systemParts.join("\n") }];
|
||||
|
||||
const roleVisitIndices: number[] = [];
|
||||
|
||||
@@ -13,9 +13,8 @@ const BUILTIN_TOOL_CALL_SCHEMA: JSONSchema = {
|
||||
export const BUILTIN_TURN_SCHEMA: JSONSchema = {
|
||||
title: "builtin-turn",
|
||||
type: "object",
|
||||
required: ["index", "role", "content"],
|
||||
required: ["role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" },
|
||||
role: { type: "string", enum: ["assistant", "tool"] },
|
||||
content: { type: "string" },
|
||||
toolCalls: {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { appendFile, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import type { BuiltinTurnPayload } from "./types.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function sessionsDir(storageRoot: string): string {
|
||||
return join(storageRoot, "sessions");
|
||||
}
|
||||
|
||||
function sessionFile(storageRoot: string, sessionId: string): string {
|
||||
return join(sessionsDir(storageRoot), `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
/** Ensure sessions directory exists. */
|
||||
export async function initSessionDir(storageRoot: string): Promise<void> {
|
||||
await mkdir(sessionsDir(storageRoot), { recursive: true });
|
||||
}
|
||||
|
||||
/** Append a turn to the session jsonl file. */
|
||||
export async function appendSessionTurn(
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
turn: BuiltinTurnPayload,
|
||||
): Promise<void> {
|
||||
const line = `${JSON.stringify(turn)}\n`;
|
||||
await appendFile(sessionFile(storageRoot, sessionId), line, "utf-8");
|
||||
log("3XQVN8KR", `session ${sessionId} appended ${turn.role} turn`);
|
||||
}
|
||||
|
||||
/** Read all turns from session jsonl. Returns empty array if file does not exist. */
|
||||
export async function readSessionTurns(
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
): Promise<BuiltinTurnPayload[]> {
|
||||
try {
|
||||
const content = await readFile(sessionFile(storageRoot, sessionId), "utf-8");
|
||||
const lines = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
return lines.map((l) => JSON.parse(l) as BuiltinTurnPayload);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove session jsonl file (called after detail is persisted to step CAS). */
|
||||
export async function removeSession(storageRoot: string, sessionId: string): Promise<void> {
|
||||
try {
|
||||
await rm(sessionFile(storageRoot, sessionId));
|
||||
log("7FWDP2MJ", `session ${sessionId} removed`);
|
||||
} catch {
|
||||
// already gone — fine
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,7 @@ function runShell(
|
||||
|
||||
export const runCommandTool: BuiltinTool = {
|
||||
name: "run_command",
|
||||
description:
|
||||
"Run a shell command. Output is truncated to 32KB.",
|
||||
description: "Run a shell command. Output is truncated to 32KB.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["command"],
|
||||
|
||||
@@ -34,7 +34,6 @@ export type BuiltinToolCall = {
|
||||
};
|
||||
|
||||
export type BuiltinTurnPayload = {
|
||||
index: number;
|
||||
role: BuiltinTurnRole;
|
||||
content: string;
|
||||
toolCalls: BuiltinToolCall[] | null;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# @uncaged/workflow-agent-claude-code
|
||||
|
||||
`uwf-claude-code` agent — spawns the Claude Code CLI and captures session detail.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 3 agent implementation. Spawns the `claude` CLI with a composed system prompt (role definition, task, prior steps, edge prompt). Parses stream or JSON stdout, caches session IDs for multi-turn continuation, and stores raw output plus structured detail in CAS.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`
|
||||
|
||||
## Installation
|
||||
|
||||
Included as the `uwf-claude-code` binary when you install `@uncaged/workflow-agent-claude-code`:
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/workflow-agent-claude-code
|
||||
```
|
||||
|
||||
Requires the `claude` CLI on `PATH`.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Invoked by `uwf thread step`:
|
||||
|
||||
```bash
|
||||
uwf-claude-code <thread-id> <role>
|
||||
```
|
||||
|
||||
Configure or override the agent:
|
||||
|
||||
```bash
|
||||
uwf setup --agent claude-code
|
||||
uwf thread step <thread-id> --agent uwf-claude-code
|
||||
```
|
||||
|
||||
Environment variables set by the engine:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Agent factory
|
||||
|
||||
```typescript
|
||||
function createClaudeCodeAgent(): () => Promise<void>
|
||||
function buildClaudeCodePrompt(ctx: AgentContext): string
|
||||
```
|
||||
|
||||
### Session detail
|
||||
|
||||
```typescript
|
||||
function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null
|
||||
function parseClaudeCodeJsonOutput(stdout: string): ClaudeCodeParsedResult | null
|
||||
function storeClaudeCodeDetail(
|
||||
store: Store,
|
||||
parsed: ClaudeCodeParsedResult,
|
||||
sessionId: string,
|
||||
): Promise<string>
|
||||
function storeClaudeCodeRawOutput(store: Store, rawOutput: string): Promise<string>
|
||||
```
|
||||
|
||||
## Usage (library)
|
||||
|
||||
```typescript
|
||||
import { createClaudeCodeAgent, buildClaudeCodePrompt } from "@uncaged/workflow-agent-claude-code";
|
||||
|
||||
const main = createClaudeCodeAgent();
|
||||
void main();
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── cli.ts Binary entrypoint
|
||||
├── claude-code.ts createClaudeCodeAgent, buildClaudeCodePrompt, spawn logic
|
||||
├── session-detail.ts Parse stdout, store CAS detail nodes
|
||||
├── schemas.ts Claude Code detail CAS schemas
|
||||
└── types.ts ClaudeCodeParsedResult, message shapes
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Uses session caching from `@uncaged/workflow-agent-kit` (`getCachedSessionId` / `setCachedSessionId`). No separate config file — relies on the Claude Code CLI's own authentication.
|
||||
|
||||
Maximum turns per invocation: 90 (constant in `claude-code.ts`).
|
||||
@@ -73,9 +73,7 @@ describe("parseClaudeCodeStreamOutput", () => {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" },
|
||||
],
|
||||
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" }],
|
||||
},
|
||||
session_id: "sess-123",
|
||||
}),
|
||||
@@ -156,6 +154,99 @@ describe("parseClaudeCodeStreamOutput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCodeStreamOutput — helper extraction", () => {
|
||||
test("processSystemLine sets model from system message", () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: "system", model: "claude-opus-4" }),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "ok",
|
||||
session_id: "s1",
|
||||
num_turns: 0,
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 0,
|
||||
stop_reason: "end_turn",
|
||||
}),
|
||||
];
|
||||
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.model).toBe("claude-opus-4");
|
||||
});
|
||||
|
||||
test("processAssistantLine skips empty content", () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: "assistant", message: { role: "assistant", content: [] } }),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "ok",
|
||||
session_id: "s1",
|
||||
num_turns: 0,
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 0,
|
||||
stop_reason: "end_turn",
|
||||
}),
|
||||
];
|
||||
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.turns).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("processUserLine skips when no tool_result items", () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: { role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "ok",
|
||||
session_id: "s1",
|
||||
num_turns: 0,
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 0,
|
||||
stop_reason: "end_turn",
|
||||
}),
|
||||
];
|
||||
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.turns).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("turn indices are sequential across mixed assistant and user lines", () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "A" }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: { role: "user", content: [{ type: "tool_result", content: "R" }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "B" }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "ok",
|
||||
session_id: "s1",
|
||||
num_turns: 3,
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 0,
|
||||
stop_reason: "end_turn",
|
||||
}),
|
||||
];
|
||||
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.turns).toHaveLength(3);
|
||||
expect(parsed!.turns.map((t) => t.index)).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeClaudeCodeDetail", () => {
|
||||
const baseParsed: ClaudeCodeParsedResult = {
|
||||
type: "result",
|
||||
@@ -167,7 +258,12 @@ describe("storeClaudeCodeDetail", () => {
|
||||
durationMs: 15000,
|
||||
model: "claude-sonnet-4.5",
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 100, outputTokens: 50, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 },
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
turns: [
|
||||
{ index: 0, role: "assistant", content: "hello", toolCalls: null },
|
||||
{ index: 1, role: "tool_result", content: "world", toolCalls: null },
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentRunResult,
|
||||
@@ -11,6 +8,7 @@ import {
|
||||
getCachedSessionId,
|
||||
setCachedSessionId,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js";
|
||||
|
||||
@@ -18,6 +16,7 @@ const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
const CLAUDE_COMMAND = "claude";
|
||||
const CLAUDE_MAX_TURNS = 90;
|
||||
const CLAUDE_MODEL = process.env.CLAUDE_MODEL ?? null;
|
||||
|
||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
if (steps.length === 0) {
|
||||
@@ -89,7 +88,7 @@ function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }
|
||||
}
|
||||
|
||||
function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnClaude([
|
||||
const args = [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
@@ -98,14 +97,18 @@ function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: strin
|
||||
"--dangerously-skip-permissions",
|
||||
"--max-turns",
|
||||
String(CLAUDE_MAX_TURNS),
|
||||
]);
|
||||
];
|
||||
if (CLAUDE_MODEL !== null) {
|
||||
args.push("--model", CLAUDE_MODEL);
|
||||
}
|
||||
return spawnClaude(args);
|
||||
}
|
||||
|
||||
function spawnClaudeResume(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnClaude([
|
||||
const args = [
|
||||
"-p",
|
||||
message,
|
||||
"--resume",
|
||||
@@ -116,7 +119,11 @@ function spawnClaudeResume(
|
||||
"--dangerously-skip-permissions",
|
||||
"--max-turns",
|
||||
String(CLAUDE_MAX_TURNS),
|
||||
]);
|
||||
];
|
||||
if (CLAUDE_MODEL !== null) {
|
||||
args.push("--model", CLAUDE_MODEL);
|
||||
}
|
||||
return spawnClaude(args);
|
||||
}
|
||||
|
||||
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
|
||||
@@ -139,17 +146,22 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
|
||||
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
|
||||
if (!ctx.isFirstVisit) {
|
||||
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role);
|
||||
const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role);
|
||||
if (cachedSessionId !== null) {
|
||||
try {
|
||||
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
|
||||
const result = await processClaudeOutput(stdout, ctx.store);
|
||||
if (result.sessionId !== undefined && result.sessionId !== "") {
|
||||
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
|
||||
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
log("5VKR8N3Q", "resume failed for session %s, falling back to fresh run: %s", cachedSessionId, err);
|
||||
log(
|
||||
"5VKR8N3Q",
|
||||
"resume failed for session %s, falling back to fresh run: %s",
|
||||
cachedSessionId,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +169,7 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const { stdout } = await spawnClaudeRun(fullPrompt);
|
||||
const result = await processClaudeOutput(stdout, ctx.store);
|
||||
if (result.sessionId !== undefined && result.sessionId !== "") {
|
||||
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
|
||||
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = {
|
||||
},
|
||||
turns: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -67,101 +67,105 @@ function extractToolResultContent(content: unknown[]): string {
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude Code stream-json (NDJSON) output.
|
||||
* Each line is a JSON object with type: "system" | "assistant" | "user" | "result".
|
||||
*/
|
||||
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
|
||||
const lines = stdout.trim().split("\n");
|
||||
const turns: ClaudeCodeTurnPayload[] = [];
|
||||
let resultLine: Record<string, unknown> | null = null;
|
||||
let model = "";
|
||||
let turnIndex = 0;
|
||||
type ParseState = {
|
||||
turns: ClaudeCodeTurnPayload[];
|
||||
resultLine: Record<string, unknown> | null;
|
||||
model: string;
|
||||
turnIndex: number;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(parsed)) continue;
|
||||
|
||||
const type = parsed.type;
|
||||
|
||||
if (type === "system" && typeof parsed.model === "string") {
|
||||
model = parsed.model;
|
||||
}
|
||||
|
||||
if (type === "assistant" && isRecord(parsed.message)) {
|
||||
const msg = parsed.message;
|
||||
const content = Array.isArray(msg.content) ? msg.content : [];
|
||||
const textContent = extractTextContent(content as unknown[]);
|
||||
const toolCalls = extractToolCalls(content as unknown[]);
|
||||
|
||||
// Only record turns that have actual content
|
||||
if (textContent !== "" || toolCalls.length > 0) {
|
||||
turns.push({
|
||||
index: turnIndex++,
|
||||
role: "assistant",
|
||||
content: textContent,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "user" && isRecord(parsed.message)) {
|
||||
const msg = parsed.message;
|
||||
const content = Array.isArray(msg.content) ? msg.content : [];
|
||||
const resultContent = extractToolResultContent(content as unknown[]);
|
||||
|
||||
if (resultContent !== "") {
|
||||
turns.push({
|
||||
index: turnIndex++,
|
||||
role: "tool_result",
|
||||
content: resultContent,
|
||||
toolCalls: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
resultLine = parsed;
|
||||
}
|
||||
function processSystemLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||
if (typeof parsed.model === "string") {
|
||||
state.model = parsed.model;
|
||||
}
|
||||
}
|
||||
|
||||
if (resultLine === null) return null;
|
||||
function processAssistantLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||
if (!isRecord(parsed.message)) return;
|
||||
const content = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
||||
const textContent = extractTextContent(content as unknown[]);
|
||||
const toolCalls = extractToolCalls(content as unknown[]);
|
||||
if (textContent !== "" || toolCalls.length > 0) {
|
||||
state.turns.push({
|
||||
index: state.turnIndex++,
|
||||
role: "assistant",
|
||||
content: textContent,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = resultLine.session_id;
|
||||
const result = resultLine.result;
|
||||
const subtype = resultLine.subtype;
|
||||
function processUserLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||
if (!isRecord(parsed.message)) return;
|
||||
const content = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
||||
const resultContent = extractToolResultContent(content as unknown[]);
|
||||
if (resultContent !== "") {
|
||||
state.turns.push({
|
||||
index: state.turnIndex++,
|
||||
role: "tool_result",
|
||||
content: resultContent,
|
||||
toolCalls: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function processLine(line: string, state: ParseState): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!isRecord(parsed)) return;
|
||||
const type = parsed.type;
|
||||
if (type === "system") processSystemLine(parsed, state);
|
||||
else if (type === "assistant") processAssistantLine(parsed, state);
|
||||
else if (type === "user") processUserLine(parsed, state);
|
||||
else if (type === "result") state.resultLine = parsed;
|
||||
}
|
||||
|
||||
function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
|
||||
if (state.resultLine === null) return null;
|
||||
const sessionId = state.resultLine.session_id;
|
||||
const result = state.resultLine.result;
|
||||
const subtype = state.resultLine.subtype;
|
||||
if (typeof sessionId !== "string" || typeof result !== "string" || typeof subtype !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usage = isRecord(resultLine.usage) ? resultLine.usage : {};
|
||||
|
||||
const usage = isRecord(state.resultLine.usage) ? state.resultLine.usage : {};
|
||||
return {
|
||||
type: safeString(resultLine.type, "result"),
|
||||
type: safeString(state.resultLine.type, "result"),
|
||||
subtype: subtype as ClaudeCodeParsedResult["subtype"],
|
||||
result,
|
||||
sessionId,
|
||||
numTurns: safeNumber(resultLine.num_turns),
|
||||
totalCostUsd: safeNumber(resultLine.total_cost_usd),
|
||||
durationMs: safeNumber(resultLine.duration_ms),
|
||||
model,
|
||||
stopReason: safeString(resultLine.stop_reason),
|
||||
numTurns: safeNumber(state.resultLine.num_turns),
|
||||
totalCostUsd: safeNumber(state.resultLine.total_cost_usd),
|
||||
durationMs: safeNumber(state.resultLine.duration_ms),
|
||||
model: state.model,
|
||||
stopReason: safeString(state.resultLine.stop_reason),
|
||||
usage: {
|
||||
inputTokens: safeNumber(usage.input_tokens),
|
||||
outputTokens: safeNumber(usage.output_tokens),
|
||||
cacheReadInputTokens: safeNumber(usage.cache_read_input_tokens),
|
||||
cacheCreationInputTokens: safeNumber(usage.cache_creation_input_tokens),
|
||||
},
|
||||
turns,
|
||||
turns: state.turns,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude Code stream-json (NDJSON) output.
|
||||
* Each line is a JSON object with type: "system" | "assistant" | "user" | "result".
|
||||
*/
|
||||
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
|
||||
const lines = stdout.trim().split("\n");
|
||||
const state: ParseState = { turns: [], resultLine: null, model: "", turnIndex: 0 };
|
||||
for (const line of lines) {
|
||||
processLine(line, state);
|
||||
}
|
||||
return assembleResult(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy: parse Claude Code plain JSON output (non-streaming).
|
||||
* Falls back when stream-json is not available.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
`uwf-hermes` agent — spawns Hermes chat via ACP and captures session detail.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 3 agent implementation. Wraps the Hermes CLI using the Agent Client Protocol (ACP). On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
|
||||
|
||||
## Installation
|
||||
|
||||
Included as the `uwf-hermes` binary when you install `@uncaged/workflow-agent-hermes`:
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/workflow-agent-hermes
|
||||
```
|
||||
|
||||
Requires the `hermes` CLI on `PATH`.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Invoked by `uwf thread step` (not typically run directly):
|
||||
|
||||
```bash
|
||||
uwf-hermes <thread-id> <role>
|
||||
```
|
||||
|
||||
Environment variables set by the engine:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
|
||||
|
||||
Configure as the default agent via `uwf setup --agent hermes`.
|
||||
|
||||
Override per step:
|
||||
|
||||
```bash
|
||||
uwf thread step <thread-id> --agent uwf-hermes
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Agent factory
|
||||
|
||||
```typescript
|
||||
function createHermesAgent(): () => Promise<void>
|
||||
function buildHermesPrompt(ctx: AgentContext): string
|
||||
```
|
||||
|
||||
### ACP client
|
||||
|
||||
```typescript
|
||||
class HermesAcpClient {
|
||||
// Spawns hermes, handles JSON-RPC over stdio
|
||||
}
|
||||
```
|
||||
|
||||
## Usage (library)
|
||||
|
||||
```typescript
|
||||
import { createHermesAgent, buildHermesPrompt } from "@uncaged/workflow-agent-hermes";
|
||||
|
||||
// CLI entry (src/cli.ts):
|
||||
const main = createHermesAgent();
|
||||
void main();
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── cli.ts Binary entrypoint
|
||||
├── hermes.ts createHermesAgent, buildHermesPrompt
|
||||
├── acp-client.ts HermesAcpClient — ACP JSON-RPC over stdio
|
||||
├── session-cache.ts Session ID cache (re-exports kit helpers + isResumeDisabled)
|
||||
├── session-detail.ts Parse Hermes session JSON, store CAS detail nodes
|
||||
├── schemas.ts Hermes detail CAS schemas
|
||||
└── types.ts HermesSessionJson, HermesSessionMessage
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Uses workflow config from `~/.uncaged/workflow/config.yaml` (via agent-kit). Hermes session files are stored under the workflow storage root (see `session-detail.ts`).
|
||||
|
||||
Set `UWF_HERMES_NO_RESUME=1` to disable session resume (see `isResumeDisabled` in `session-cache.ts`).
|
||||
@@ -4,6 +4,96 @@ import { HermesAcpClient } from "../src/acp-client.js";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
describe("handleSessionUpdate — helper extraction", () => {
|
||||
let client: HermesAcpClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new HermesAcpClient();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("agent_message_chunk accumulates text in messageChunks", () => {
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "hello" },
|
||||
});
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: " world" },
|
||||
});
|
||||
expect((client as any).messageChunks).toEqual(["hello", " world"]);
|
||||
});
|
||||
|
||||
it("agent_thought_chunk accumulates reasoning in reasoningChunks", () => {
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: "thinking" },
|
||||
});
|
||||
expect((client as any).reasoningChunks).toEqual(["thinking"]);
|
||||
});
|
||||
|
||||
it("tool_call registers a pending tool and flushes message chunks", () => {
|
||||
(client as any).messageChunks = ["pre-tool text"];
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "tool_call",
|
||||
title: "Bash",
|
||||
rawInput: { command: "ls" },
|
||||
toolCallId: "tc-1",
|
||||
});
|
||||
expect((client as any).pendingTools.get("tc-1")).toEqual({
|
||||
name: "Bash",
|
||||
args: JSON.stringify({ command: "ls" }),
|
||||
});
|
||||
expect((client as any).messageChunks).toEqual([]);
|
||||
expect((client as any).messages).toHaveLength(1);
|
||||
expect((client as any).messages[0].role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("tool_call_update completed pushes tool_call and tool messages", () => {
|
||||
(client as any).pendingTools.set("tc-2", { name: "Read", args: '{"path":"/foo"}' });
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "completed",
|
||||
toolCallId: "tc-2",
|
||||
rawOutput: "file contents",
|
||||
});
|
||||
const msgs = (client as any).messages as Array<{
|
||||
role: string;
|
||||
tool_calls: unknown;
|
||||
content: string | null;
|
||||
}>;
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs[0].role).toBe("assistant");
|
||||
expect(msgs[0].tool_calls).toEqual([
|
||||
{ function: { name: "Read", arguments: '{"path":"/foo"}' } },
|
||||
]);
|
||||
expect(msgs[1].role).toBe("tool");
|
||||
expect(msgs[1].content).toBe("file contents");
|
||||
expect((client as any).pendingTools.has("tc-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("tool_call_update with non-string rawOutput JSON-stringifies it", () => {
|
||||
(client as any).pendingTools.set("tc-3", { name: "Fetch", args: "" });
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "completed",
|
||||
toolCallId: "tc-3",
|
||||
rawOutput: { html: "<p>page</p>" },
|
||||
});
|
||||
const msgs = (client as any).messages as Array<{ role: string; content: string | null }>;
|
||||
expect(msgs[1].content).toBe(JSON.stringify({ html: "<p>page</p>" }));
|
||||
});
|
||||
|
||||
it("unknown updateType is a no-op", () => {
|
||||
(client as any).handleSessionUpdate({ sessionUpdate: "unknown_type", data: {} });
|
||||
expect((client as any).messages).toHaveLength(0);
|
||||
expect((client as any).messageChunks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HermesAcpClient", () => {
|
||||
let client: HermesAcpClient;
|
||||
|
||||
@@ -54,7 +144,8 @@ describe("HermesAcpClient", () => {
|
||||
{ timeout: 2 * 60 * 1000 },
|
||||
);
|
||||
|
||||
it(
|
||||
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
|
||||
it.skip(
|
||||
"prompt() collects structured messages including tool calls",
|
||||
async () => {
|
||||
await client.connect(process.cwd());
|
||||
|
||||
@@ -21,7 +21,8 @@ describe("HermesAcpClient cross-process resume", () => {
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it(
|
||||
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
|
||||
it.skip(
|
||||
"resume() after close — second prompt returns non-empty text",
|
||||
async () => {
|
||||
// --- Client A: first run ---
|
||||
|
||||
@@ -245,73 +245,75 @@ export class HermesAcpClient {
|
||||
// ---- Session update → structured messages ----
|
||||
|
||||
private handleSessionUpdate(update: Record<string, unknown>): void {
|
||||
const updateType = update.sessionUpdate as string;
|
||||
|
||||
switch (updateType) {
|
||||
case "agent_message_chunk": {
|
||||
const content = update.content as { type?: string; text?: string } | undefined;
|
||||
if (content?.type === "text" && typeof content.text === "string") {
|
||||
this.messageChunks.push(content.text);
|
||||
}
|
||||
switch (update.sessionUpdate as string) {
|
||||
case "agent_message_chunk":
|
||||
this.handleAgentMessageChunk(update);
|
||||
break;
|
||||
}
|
||||
|
||||
case "agent_thought_chunk": {
|
||||
const content = update.content as { type?: string; text?: string } | undefined;
|
||||
if (content?.type === "text" && typeof content.text === "string") {
|
||||
this.reasoningChunks.push(content.text);
|
||||
}
|
||||
case "agent_thought_chunk":
|
||||
this.handleAgentThoughtChunk(update);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_call": {
|
||||
const title = (update.title as string) ?? "";
|
||||
const rawInput = update.rawInput;
|
||||
const args =
|
||||
rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
|
||||
const toolCallId = update.toolCallId as string;
|
||||
this.pendingTools.set(toolCallId, { name: title, args });
|
||||
|
||||
// Flush accumulated assistant text before tool call
|
||||
this.flushAssistantMessage();
|
||||
case "tool_call":
|
||||
this.handleToolCall(update);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_call_update": {
|
||||
const status = update.status as string | undefined;
|
||||
if (status === "completed" || status === "failed") {
|
||||
const toolCallId = update.toolCallId as string;
|
||||
const pending = this.pendingTools.get(toolCallId);
|
||||
const toolName = pending?.name ?? toolCallId;
|
||||
const rawOutput = update.rawOutput;
|
||||
const outputStr =
|
||||
rawOutput !== undefined && rawOutput !== null
|
||||
? typeof rawOutput === "string"
|
||||
? rawOutput
|
||||
: JSON.stringify(rawOutput)
|
||||
: "";
|
||||
this.messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning: null,
|
||||
tool_calls: [{ function: { name: toolName, arguments: pending?.args ?? "" } }],
|
||||
});
|
||||
this.messages.push({
|
||||
role: "tool",
|
||||
content: outputStr,
|
||||
reasoning: null,
|
||||
tool_calls: null,
|
||||
});
|
||||
this.pendingTools.delete(toolCallId);
|
||||
}
|
||||
case "tool_call_update":
|
||||
this.handleToolCallUpdate(update);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentMessageChunk(update: Record<string, unknown>): void {
|
||||
const content = update.content as { type?: string; text?: string } | undefined;
|
||||
if (content?.type === "text" && typeof content.text === "string") {
|
||||
this.messageChunks.push(content.text);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentThoughtChunk(update: Record<string, unknown>): void {
|
||||
const content = update.content as { type?: string; text?: string } | undefined;
|
||||
if (content?.type === "text" && typeof content.text === "string") {
|
||||
this.reasoningChunks.push(content.text);
|
||||
}
|
||||
}
|
||||
|
||||
private handleToolCall(update: Record<string, unknown>): void {
|
||||
const title = (update.title as string) ?? "";
|
||||
const rawInput = update.rawInput;
|
||||
const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
|
||||
const toolCallId = update.toolCallId as string;
|
||||
this.pendingTools.set(toolCallId, { name: title, args });
|
||||
this.flushAssistantMessage();
|
||||
}
|
||||
|
||||
private handleToolCallUpdate(update: Record<string, unknown>): void {
|
||||
const status = update.status as string | undefined;
|
||||
if (status !== "completed" && status !== "failed") return;
|
||||
const toolCallId = update.toolCallId as string;
|
||||
const pending = this.pendingTools.get(toolCallId);
|
||||
const toolName = pending?.name ?? toolCallId;
|
||||
const rawOutput = update.rawOutput;
|
||||
const outputStr =
|
||||
rawOutput !== undefined && rawOutput !== null
|
||||
? typeof rawOutput === "string"
|
||||
? rawOutput
|
||||
: JSON.stringify(rawOutput)
|
||||
: "";
|
||||
this.messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning: null,
|
||||
tool_calls: [{ function: { name: toolName, arguments: pending?.args ?? "" } }],
|
||||
});
|
||||
this.messages.push({
|
||||
role: "tool",
|
||||
content: outputStr,
|
||||
reasoning: null,
|
||||
tool_calls: null,
|
||||
});
|
||||
this.pendingTools.delete(toolCallId);
|
||||
}
|
||||
|
||||
/** Flush any accumulated text/reasoning into an assistant message. */
|
||||
private flushAssistantMessage(): void {
|
||||
const text = this.messageChunks.join("");
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
// Re-export session cache from the shared agent-kit package.
|
||||
export { getCachedSessionId, setCachedSessionId } from "@uncaged/workflow-agent-kit";
|
||||
// Re-export session cache from the shared agent-kit package with agent name injected.
|
||||
|
||||
import {
|
||||
getCachedSessionId as getCachedSessionIdBase,
|
||||
setCachedSessionId as setCachedSessionIdBase,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||
|
||||
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
||||
return getCachedSessionIdBase("hermes", threadId, role);
|
||||
}
|
||||
|
||||
export async function setCachedSessionId(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
return setCachedSessionIdBase("hermes", threadId, role, sessionId);
|
||||
}
|
||||
|
||||
export function isResumeDisabled(): boolean {
|
||||
// Hermes ACP session/resume is broken: _restore fails for custom providers
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# @uncaged/workflow-agent-kit
|
||||
|
||||
Agent framework — `createAgent` factory, context builder, frontmatter fast-path, and LLM extract pipeline.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 2 agent framework. Provides the standard entrypoint for all agent CLIs: parse `<thread-id> <role>` from argv, load thread/workflow context from CAS, invoke the agent's `run`/`continue` functions, validate output via frontmatter fast-path or LLM extract, and write a `StepNodePayload` to CAS.
|
||||
|
||||
Also exports prompt builders, config/storage helpers, and session ID caching for multi-turn agents.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `dotenv`, `yaml`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-kit
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Agent factory
|
||||
|
||||
```typescript
|
||||
function createAgent(options: AgentOptions): () => Promise<void>
|
||||
|
||||
type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
|
||||
type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
sessionId: string;
|
||||
};
|
||||
```
|
||||
|
||||
Agent CLIs call `createAgent(...)` and invoke the returned function as `main()`.
|
||||
|
||||
### Context
|
||||
|
||||
```typescript
|
||||
function buildContext(threadId: ThreadId, role: string): Promise<AgentContext>
|
||||
function buildContextWithMeta(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
): Promise<AgentContext & { meta: BuildContextMeta }>
|
||||
|
||||
type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
outputFormatInstruction: string;
|
||||
edgePrompt: string;
|
||||
isFirstVisit: boolean;
|
||||
};
|
||||
|
||||
type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
```
|
||||
|
||||
Requires `UWF_EDGE_PROMPT` in the environment (set by `uwf thread step`).
|
||||
|
||||
### Prompt builders
|
||||
|
||||
```typescript
|
||||
function buildRolePrompt(role: RoleDefinition): string
|
||||
function buildOutputFormatInstruction(schema: JSONSchema): string
|
||||
function buildContinuationPrompt(
|
||||
ctx: AgentContext,
|
||||
priorOutput: string,
|
||||
instruction: string,
|
||||
): string
|
||||
```
|
||||
|
||||
### Extract pipeline
|
||||
|
||||
```typescript
|
||||
function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias
|
||||
function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider
|
||||
function extract(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
config: WorkflowConfig,
|
||||
): Promise<ExtractResult>
|
||||
|
||||
type ResolvedLlmProvider = { baseUrl: string; apiKey: string; model: string };
|
||||
type ExtractResult = { value: unknown; hash: CasRef };
|
||||
```
|
||||
|
||||
### Frontmatter fast-path
|
||||
|
||||
```typescript
|
||||
function tryFrontmatterFastPath(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
store: Store,
|
||||
): Promise<FrontmatterFastPathResult | null>
|
||||
|
||||
type FrontmatterFastPathResult = { body: string; outputHash: CasRef };
|
||||
```
|
||||
|
||||
### Session cache
|
||||
|
||||
```typescript
|
||||
function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null>
|
||||
function setCachedSessionId(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
sessionId: string,
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
### Config and storage
|
||||
|
||||
```typescript
|
||||
function getConfigPath(storageRoot: string): string
|
||||
function getEnvPath(storageRoot: string): string
|
||||
function resolveStorageRoot(): string
|
||||
function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createAgent, buildRolePrompt } from "@uncaged/workflow-agent-kit";
|
||||
import type { AgentContext, AgentRunResult } from "@uncaged/workflow-agent-kit";
|
||||
|
||||
async function run(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const prompt = buildRolePrompt(ctx.workflow.roles[ctx.role]!);
|
||||
// ... spawn external process, capture output ...
|
||||
return { output: markdown, detailHash: "...", sessionId: "..." };
|
||||
}
|
||||
|
||||
async function continueSession(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<AgentRunResult> {
|
||||
// ... continue multi-turn session ...
|
||||
return { output: markdown, detailHash: "...", sessionId };
|
||||
}
|
||||
|
||||
export const main = createAgent({ name: "my-agent", run, continue: continueSession });
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── run.ts createAgent entrypoint
|
||||
├── context.ts Thread chain walk, AgentContext builder
|
||||
├── extract.ts LLM structured extract fallback
|
||||
├── frontmatter.ts Frontmatter fast-path validation
|
||||
├── build-role-prompt.ts Role definition → prompt text
|
||||
├── build-output-format-instruction.ts
|
||||
├── build-continuation-prompt.ts
|
||||
├── session-cache.ts Per-thread/session ID persistence
|
||||
├── storage.ts CAS store, config, threads index
|
||||
├── schemas.ts Agent CAS schema registration
|
||||
└── types.ts AgentContext, AgentOptions, etc.
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Reads `config.yaml` and `.env` from the workflow storage root (`~/.uncaged/workflow` by default). See `@uncaged/workflow-protocol` for `WorkflowConfig` shape. Set via `uwf setup`.
|
||||
@@ -0,0 +1,247 @@
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { getCachedSessionId, getCachePath, setCachedSessionId } from "../src/session-cache.js";
|
||||
import { resolveStorageRoot } from "../src/storage.js";
|
||||
|
||||
describe("session-cache", () => {
|
||||
let originalStorageRoot: string;
|
||||
let testStorageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary test storage root
|
||||
originalStorageRoot = resolveStorageRoot();
|
||||
testStorageRoot = join(originalStorageRoot, "test-cache", `test-${Date.now()}`);
|
||||
await mkdir(testStorageRoot, { recursive: true });
|
||||
|
||||
// Override the storage root for testing
|
||||
process.env.WORKFLOW_STORAGE_ROOT = testStorageRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test storage root
|
||||
await rm(testStorageRoot, { recursive: true, force: true });
|
||||
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||
});
|
||||
|
||||
describe("getCachePath", () => {
|
||||
test("returns agent-specific file path", () => {
|
||||
const path = getCachePath("claude-code");
|
||||
expect(path).toMatch(/\/cache\/claude-code-sessions\.json$/);
|
||||
});
|
||||
|
||||
test("returns different paths for different agents", () => {
|
||||
const pathClaudeCode = getCachePath("claude-code");
|
||||
const pathHermes = getCachePath("hermes");
|
||||
|
||||
expect(pathClaudeCode).not.toBe(pathHermes);
|
||||
expect(pathClaudeCode).toMatch(/claude-code-sessions\.json$/);
|
||||
expect(pathHermes).toMatch(/hermes-sessions\.json$/);
|
||||
});
|
||||
|
||||
test("handles agent names with special characters", () => {
|
||||
const path1 = getCachePath("my-agent");
|
||||
const path2 = getCachePath("my_agent");
|
||||
|
||||
expect(path1).toMatch(/my-agent-sessions\.json$/);
|
||||
expect(path2).toMatch(/my_agent-sessions\.json$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session isolation", () => {
|
||||
const threadId = "01234567890123456789012345" as ThreadId;
|
||||
const role = "developer";
|
||||
|
||||
test("sessions are isolated per agent", async () => {
|
||||
// Cache different session IDs for each agent
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
|
||||
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
|
||||
|
||||
// Each agent should retrieve its own session ID
|
||||
const sessionCC = await getCachedSessionId("claude-code", threadId, role);
|
||||
const sessionHermes = await getCachedSessionId("hermes", threadId, role);
|
||||
|
||||
expect(sessionCC).toBe("session-cc-001");
|
||||
expect(sessionHermes).toBe("session-hermes-001");
|
||||
});
|
||||
|
||||
test("updating one agent's cache does not affect another", async () => {
|
||||
// Set initial sessions for both agents
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
|
||||
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
|
||||
|
||||
// Update claude-code's session
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-cc-002");
|
||||
|
||||
// Hermes's session should remain unchanged
|
||||
const sessionHermes = await getCachedSessionId("hermes", threadId, role);
|
||||
expect(sessionHermes).toBe("session-hermes-001");
|
||||
|
||||
// Claude-code should have the new session
|
||||
const sessionCC = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(sessionCC).toBe("session-cc-002");
|
||||
});
|
||||
|
||||
test("missing session returns null for specific agent", async () => {
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
test("empty session ID is treated as missing", async () => {
|
||||
await setCachedSessionId("claude-code", threadId, role, "");
|
||||
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("file system operations", () => {
|
||||
const threadId = "01234567890123456789012345" as ThreadId;
|
||||
const role = "developer";
|
||||
|
||||
test("cache directory is created if missing", async () => {
|
||||
const cachePath = getCachePath("claude-code");
|
||||
const cacheDir = dirname(cachePath);
|
||||
|
||||
// Ensure cache dir doesn't exist
|
||||
await rm(cacheDir, { recursive: true, force: true });
|
||||
|
||||
// Write a session
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-001");
|
||||
|
||||
// Cache directory should be created
|
||||
const stats = await stat(cacheDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
test("multiple agents create separate cache files", async () => {
|
||||
// Cache sessions for multiple agents
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
|
||||
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
|
||||
|
||||
// Separate cache files should exist
|
||||
const pathCC = getCachePath("claude-code");
|
||||
const pathHermes = getCachePath("hermes");
|
||||
|
||||
const contentCC = JSON.parse(await readFile(pathCC, "utf8")) as Record<string, string>;
|
||||
const contentHermes = JSON.parse(await readFile(pathHermes, "utf8")) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
expect(contentCC).toHaveProperty(`${threadId}:${role}`, "session-cc-001");
|
||||
expect(contentHermes).toHaveProperty(`${threadId}:${role}`, "session-hermes-001");
|
||||
});
|
||||
|
||||
test("atomic writes prevent partial reads", async () => {
|
||||
// Write a session
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-001");
|
||||
|
||||
// The final file should exist (no .tmp files left behind)
|
||||
const cachePath = getCachePath("claude-code");
|
||||
const dir = dirname(cachePath);
|
||||
const files = await readdir(dir);
|
||||
|
||||
expect(files).toContain("claude-code-sessions.json");
|
||||
expect(files.every((f) => !f.endsWith(".tmp"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy migration", () => {
|
||||
const threadId = "01234567890123456789012345" as ThreadId;
|
||||
const role = "developer";
|
||||
|
||||
test("old agent-sessions.json is ignored", async () => {
|
||||
// Create old agent-sessions.json file
|
||||
const oldCachePath = join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
||||
await mkdir(dirname(oldCachePath), { recursive: true });
|
||||
await writeFile(
|
||||
oldCachePath,
|
||||
JSON.stringify({
|
||||
"01234567890123456789012345:developer": "old-session-001",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Query with the new per-agent cache
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
|
||||
// Should return null (old cache is ignored)
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
test("new per-agent cache takes precedence", async () => {
|
||||
// Create both old and new cache files
|
||||
const oldPath = join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
||||
await mkdir(dirname(oldPath), { recursive: true });
|
||||
await writeFile(
|
||||
oldPath,
|
||||
JSON.stringify({
|
||||
[`${threadId}:${role}`]: "old-session",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await setCachedSessionId("claude-code", threadId, role, "new-session");
|
||||
|
||||
// The new per-agent cache value should be returned
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBe("new-session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
const threadId = "01234567890123456789012345" as ThreadId;
|
||||
const role = "developer";
|
||||
|
||||
test("invalid JSON in cache file returns empty cache", async () => {
|
||||
// Create a corrupted cache file
|
||||
const cachePath = getCachePath("claude-code");
|
||||
await mkdir(dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, "{ invalid json }", "utf8");
|
||||
|
||||
// Should return null (treating corrupted cache as empty)
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
test("non-object JSON in cache file returns empty cache", async () => {
|
||||
// Create a cache file with non-object JSON
|
||||
const cachePath = getCachePath("claude-code");
|
||||
await mkdir(dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, JSON.stringify(["not", "an", "object"]), "utf8");
|
||||
|
||||
// Should return null
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
test("cache entries with non-string values are ignored", async () => {
|
||||
// Create a cache file with mixed types
|
||||
const cachePath = getCachePath("claude-code");
|
||||
const cacheData = {
|
||||
"thread1:role1": "valid-session",
|
||||
"thread2:role2": 12345, // number
|
||||
"thread3:role3": null, // null
|
||||
"thread4:role4": "", // empty string
|
||||
};
|
||||
await mkdir(dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, JSON.stringify(cacheData), "utf8");
|
||||
|
||||
// Valid string entries should be returned
|
||||
const session1 = await getCachedSessionId("claude-code", "thread1" as ThreadId, "role1");
|
||||
expect(session1).toBe("valid-session");
|
||||
|
||||
// Invalid entries should return null
|
||||
const session2 = await getCachedSessionId("claude-code", "thread2" as ThreadId, "role2");
|
||||
const session3 = await getCachedSessionId("claude-code", "thread3" as ThreadId, "role3");
|
||||
const session4 = await getCachedSessionId("claude-code", "thread4" as ThreadId, "role4");
|
||||
|
||||
expect(session2).toBeNull();
|
||||
expect(session3).toBeNull();
|
||||
expect(session4).toBeNull(); // empty string is treated as missing
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,14 +21,6 @@ function fail(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function readEdgePrompt(): string {
|
||||
const value = process.env.UWF_EDGE_PROMPT;
|
||||
if (value === undefined || value === "") {
|
||||
fail("UWF_EDGE_PROMPT environment variable is required");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function walkChain(store: Store, schemas: AgentStore["schemas"], headHash: CasRef): ChainState {
|
||||
const headNode = store.get(headHash);
|
||||
if (headNode === null) {
|
||||
@@ -123,7 +115,11 @@ async function loadWorkflow(store: Store, schemas: AgentStore["schemas"], workfl
|
||||
* Build agent execution context from thread head in threads.yaml.
|
||||
* Walks the CAS chain from head to StartNode and expands step outputs.
|
||||
*/
|
||||
export async function buildContext(threadId: ThreadId, role: string): Promise<AgentContext> {
|
||||
export async function buildContext(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
): Promise<AgentContext> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
const agentStore = await createAgentStore(storageRoot);
|
||||
const { store, schemas } = agentStore;
|
||||
@@ -142,7 +138,6 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const edgePrompt = readEdgePrompt();
|
||||
const isFirstVisit = !steps.some((s) => s.role === role);
|
||||
|
||||
return {
|
||||
@@ -172,6 +167,7 @@ export type BuildContextMeta = {
|
||||
export async function buildContextWithMeta(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
): Promise<AgentContext & { meta: BuildContextMeta }> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
const agentStore = await createAgentStore(storageRoot);
|
||||
@@ -191,7 +187,6 @@ export async function buildContextWithMeta(
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const edgePrompt = readEdgePrompt();
|
||||
const isFirstVisit = !steps.some((s) => s.role === role);
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,8 +12,8 @@ export {
|
||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
|
||||
export type {
|
||||
AgentContext,
|
||||
AgentContinueFn,
|
||||
|
||||
@@ -22,16 +22,24 @@ function agentLabel(name: string): string {
|
||||
return `uwf-${name}`;
|
||||
}
|
||||
|
||||
function parseArgv(argv: string[]): { threadId: ThreadId; role: string } {
|
||||
const threadId = argv[2];
|
||||
const role = argv[3];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
fail("usage: <agent-cli> <thread-id> <role>");
|
||||
const USAGE = "usage: <agent-cli> --thread <id> --role <role> --prompt <text>";
|
||||
|
||||
function getNamedArg(argv: string[], name: string): string {
|
||||
const idx = argv.indexOf(name);
|
||||
if (idx === -1 || idx + 1 >= argv.length) {
|
||||
return "";
|
||||
}
|
||||
if (role === undefined || role === "") {
|
||||
fail("usage: <agent-cli> <thread-id> <role>");
|
||||
}
|
||||
return { threadId: threadId as ThreadId, role };
|
||||
return argv[idx + 1];
|
||||
}
|
||||
|
||||
function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
|
||||
const threadId = getNamedArg(argv, "--thread");
|
||||
const role = getNamedArg(argv, "--role");
|
||||
const prompt = getNamedArg(argv, "--prompt");
|
||||
if (threadId === "") fail(USAGE);
|
||||
if (role === "") fail(USAGE);
|
||||
if (prompt === "") fail(USAGE);
|
||||
return { threadId: threadId as ThreadId, role, prompt };
|
||||
}
|
||||
|
||||
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
@@ -103,11 +111,11 @@ async function persistStep(options: {
|
||||
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
const { threadId, role, prompt } = parseArgv(process.argv);
|
||||
const storageRoot = resolveStorageRoot();
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
|
||||
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role));
|
||||
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role, prompt));
|
||||
|
||||
const roleDef = ctx.workflow.roles[role];
|
||||
if (roleDef === undefined) {
|
||||
@@ -121,6 +129,11 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
|
||||
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
|
||||
|
||||
// Preserve the primary detail from the first run — it contains the full
|
||||
// tool-call turn history. Continuation retries only fix frontmatter
|
||||
// formatting and their 1-turn detail is not meaningful.
|
||||
const primaryDetailHash = agentResult.detailHash;
|
||||
|
||||
// Try to extract frontmatter; retry via continue if it fails
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
@@ -147,7 +160,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
detailHash: agentResult.detailHash,
|
||||
detailHash: primaryDetailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||
@@ -8,8 +8,8 @@ import { resolveStorageRoot } from "./storage.js";
|
||||
|
||||
type SessionCache = Record<string, string>;
|
||||
|
||||
function getCachePath(): string {
|
||||
return join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
||||
export function getCachePath(agentName: string): string {
|
||||
return join(resolveStorageRoot(), "cache", `${agentName}-sessions.json`);
|
||||
}
|
||||
|
||||
function cacheKey(threadId: ThreadId, role: string): string {
|
||||
@@ -20,8 +20,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readCache(): Promise<SessionCache> {
|
||||
const path = getCachePath();
|
||||
async function readCache(agentName: string): Promise<SessionCache> {
|
||||
const path = getCachePath(agentName);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = JSON.parse(text) as unknown;
|
||||
@@ -40,36 +40,45 @@ async function readCache(): Promise<SessionCache> {
|
||||
if (err.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
// Treat JSON parse errors as empty cache
|
||||
if (err.name === "SyntaxError") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(cache: SessionCache): Promise<void> {
|
||||
const path = getCachePath();
|
||||
async function writeCache(agentName: string, cache: SessionCache): Promise<void> {
|
||||
const path = getCachePath(agentName);
|
||||
const dir = dirname(path);
|
||||
await mkdir(dir, { recursive: true });
|
||||
// Atomic write: write to temp file then rename to avoid partial reads on concurrent access.
|
||||
// NOTE: Current workflow execution is serial (execFileSync), so true concurrency doesn't occur.
|
||||
// This is a safety net for future parallel execution.
|
||||
const tmpPath = join(dir, `.agent-sessions.${randomBytes(4).toString("hex")}.tmp`);
|
||||
const tmpPath = join(dir, `.${agentName}-sessions.${randomBytes(4).toString("hex")}.tmp`);
|
||||
await writeFile(tmpPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
||||
await rename(tmpPath, path);
|
||||
}
|
||||
|
||||
/** Read the cached session ID for a thread+role pair. */
|
||||
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
||||
const cache = await readCache();
|
||||
export async function getCachedSessionId(
|
||||
agentName: string,
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
): Promise<string | null> {
|
||||
const cache = await readCache(agentName);
|
||||
const sessionId = cache[cacheKey(threadId, role)];
|
||||
return sessionId ?? null;
|
||||
}
|
||||
|
||||
/** Write the session ID for a thread+role pair into the cache. */
|
||||
export async function setCachedSessionId(
|
||||
agentName: string,
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const cache = await readCache();
|
||||
const cache = await readCache(agentName);
|
||||
cache[cacheKey(threadId, role)] = sessionId;
|
||||
await writeCache(cache);
|
||||
await writeCache(agentName, cache);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export type AgentContext = ModeratorContext & {
|
||||
*/
|
||||
outputFormatInstruction: string;
|
||||
/**
|
||||
* Edge prompt from the graph transition that led to this role (UWF_EDGE_PROMPT).
|
||||
* Edge prompt from the graph transition that led to this role (--prompt CLI arg).
|
||||
* Always the real moderator instruction for this step.
|
||||
*/
|
||||
edgePrompt: string;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# @uncaged/workflow-dashboard
|
||||
|
||||
Web graph editor for visualizing and editing workflow YAML definitions.
|
||||
|
||||
## Overview
|
||||
|
||||
A private alpha web app (not part of the runtime engine stack). Provides a React + `@xyflow/react` canvas for editing workflow roles, conditions, and graph transitions. Uses `@uncaged/workflow-protocol` types for validation and YAML round-tripping.
|
||||
|
||||
Planned integration: local `uwf connect` over WebSocket to sync YAML between CLI and the browser editor. The REST API and Elysia backend are currently stubs for development.
|
||||
|
||||
**Dependencies:** `@uncaged/workflow-protocol`, `@xyflow/react`, React 19, react-router v7, Vite 8, Tailwind CSS v4, Elysia
|
||||
|
||||
## Installation
|
||||
|
||||
Monorepo-only ( `"private": true` ). Not published to npm.
|
||||
|
||||
```bash
|
||||
cd packages/workflow-dashboard
|
||||
bun install --no-cache
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Start the Vite dev server (port 3000):
|
||||
|
||||
```bash
|
||||
cd packages/workflow-dashboard
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Build for production:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` in a browser.
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
workflow-dashboard/
|
||||
├── server.ts Vite dev server entry (port 3000)
|
||||
├── vite.config.ts Vite + React + Tailwind + Elysia plugin
|
||||
├── vite-dev.ts Custom Vite plugin
|
||||
├── index.html
|
||||
├── components.json shadcn configuration
|
||||
├── server/
|
||||
│ ├── api.ts Elysia REST API (health + workflow CRUD stub)
|
||||
│ └── workflow.ts Workflow file read/write + format conversion
|
||||
└── src/
|
||||
├── main.tsx React DOM entry
|
||||
├── app.tsx Root layout
|
||||
├── router.tsx Hash-mode routes
|
||||
├── index.css
|
||||
├── lib/utils.ts Tailwind cn() helper
|
||||
├── components/ui/ shadcn components (button, card, dialog, input, …)
|
||||
├── pages/
|
||||
│ ├── home.tsx Workflow list
|
||||
│ ├── detail.tsx Workflow detail view
|
||||
│ └── editor.tsx Full editor page
|
||||
└── editor/ Core graph editor
|
||||
├── flow.tsx FlowEditor component
|
||||
├── context.tsx State (useSyncExternalStore + Immer)
|
||||
├── injection.ts DI container
|
||||
├── type.ts Internal editor types
|
||||
├── model/ Node/edge state model
|
||||
├── nodes/ Start, role, end node components
|
||||
├── edges/ Conditional edge rendering
|
||||
├── panel/ Toolbar, add/edit panels
|
||||
├── trans/ YAML ↔ graph conversion (trans-in, trans-out, validate)
|
||||
├── layout/ Auto-layout
|
||||
└── utils/ Event helpers, click-outside hook
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---------|---------|-------|
|
||||
| Dev server port | `3000` | Set in `server.ts` |
|
||||
| Workflow storage (dev) | `tmp/workflow/` | YAML files during development |
|
||||
| Path alias | `@/` → `src/` | Configured in `vite.config.ts` |
|
||||
|
||||
No library API — this package is an application, not importable as a module.
|
||||
@@ -6,8 +6,8 @@
|
||||
<title>Workflow UI</title>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem("theme");
|
||||
(() => {
|
||||
const t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/ui": "^4.1.7",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.13"
|
||||
"vite": "^8.0.13",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,3 @@ const server = await createServer({
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
|
||||
// biome-ignore lint/nursery/noConsole: CLI user-facing output
|
||||
console.log(`Workflow UI running at http://localhost:${PORT}`);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import type { WorkFlowSteps } from "../shared/types.ts";
|
||||
import {
|
||||
listWorkflows,
|
||||
getWorkflow,
|
||||
createWorkflow,
|
||||
saveWorkflow,
|
||||
deleteWorkflow,
|
||||
getWorkflow,
|
||||
listWorkflows,
|
||||
saveWorkflow,
|
||||
} from "./workflow.ts";
|
||||
|
||||
export function createApi() {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { readdir, readFile, writeFile, unlink, mkdir } from "node:fs/promises";
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import YAML from "yaml";
|
||||
import type {
|
||||
WorkflowPayload,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||
|
||||
const WORKFLOW_DIR = join(import.meta.dirname, "..", "tmp", "workflow");
|
||||
@@ -67,7 +63,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
let condName: string | null = null;
|
||||
if (t.condition) {
|
||||
if (expressionToName.has(t.condition)) {
|
||||
condName = expressionToName.get(t.condition)!;
|
||||
condName = expressionToName.get(t.condition) ?? null;
|
||||
} else {
|
||||
condName = `cond${condIdx++}`;
|
||||
expressionToName.set(t.condition, condName);
|
||||
@@ -90,7 +86,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
|
||||
if (steps.length > 0) {
|
||||
const firstRole = steps[0].role.name;
|
||||
graph["$START"] = [
|
||||
graph.$START = [
|
||||
{
|
||||
role: firstRole,
|
||||
condition: null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
@@ -37,8 +37,8 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -52,7 +52,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
@@ -13,11 +13,11 @@ function Card({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -53,20 +53,17 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -85,19 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -43,7 +39,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
@@ -52,7 +48,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -60,32 +56,21 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@@ -94,54 +79,46 @@ function DialogFooter({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
className={cn("font-heading text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -155,4 +132,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noLabelWithoutControl: generic Label component; control association handled by consumer
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createContext, useMemo, useSyncExternalStore, useContext, useLayoutEffect } from 'react';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useReactFlow, ReactFlowInstance } from '@xyflow/react';
|
||||
import type { AnyWorkNode } from './type';
|
||||
import { type ReactFlowInstance, useReactFlow } from "@xyflow/react";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useLayoutEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import type { AnyWorkNode } from "./type";
|
||||
|
||||
type Reduce<T> = (data: T) => T;
|
||||
type Setter<T> = (ch: Reduce<T> | T) => void;
|
||||
|
||||
interface State<T, A> {
|
||||
interface State<T, A> {
|
||||
readonly get: () => T;
|
||||
readonly set: Setter<T>;
|
||||
readonly use: () => T;
|
||||
@@ -15,6 +15,7 @@ interface State<T, A> {
|
||||
readonly onlyView: boolean;
|
||||
}
|
||||
type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
|
||||
// biome-ignore lint/suspicious/noExplicitAny: UseV intentionally erases the action type
|
||||
type UseV = <T>(sub: SubModel<T, any>) => T;
|
||||
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
|
||||
|
||||
@@ -24,10 +25,12 @@ export function generate<T>(val: T) {
|
||||
const listener = new Set<VoidFunction>();
|
||||
const get = () => val;
|
||||
function set(ch: T | ((prev: T) => T)) {
|
||||
const next = (typeof ch === 'function') ? (ch as (prev: T) => T)(val) : ch;
|
||||
const next = typeof ch === "function" ? (ch as (prev: T) => T)(val) : ch;
|
||||
if (Object.is(val, next)) return;
|
||||
val = next;
|
||||
listener.forEach(call => call());
|
||||
for (const call of listener) {
|
||||
call();
|
||||
}
|
||||
}
|
||||
const listen = (call: VoidFunction) => {
|
||||
listener.add(call);
|
||||
@@ -38,21 +41,26 @@ export function generate<T>(val: T) {
|
||||
}
|
||||
|
||||
class SubModel<T, A> {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private make: () => T,
|
||||
private create: Create<T, A>,
|
||||
private onlyView = false,
|
||||
) {}
|
||||
public readonly name: string;
|
||||
private readonly make: () => T;
|
||||
private readonly create: Create<T, A>;
|
||||
private readonly onlyView: boolean;
|
||||
|
||||
constructor(name: string, _make: () => T, _create: Create<T, A>, _onlyView = false) {
|
||||
this.name = name;
|
||||
this.make = _make;
|
||||
this.create = _create;
|
||||
this.onlyView = _onlyView;
|
||||
}
|
||||
|
||||
public gen(model: Model): State<T, A> {
|
||||
const { make, create, onlyView } = this;
|
||||
const { get, set, use, listen } = generate(make());
|
||||
const actions = create(set, get, model);
|
||||
return { get, set, use, listen, actions, onlyView };
|
||||
const { get, set, use, listen } = generate(this.make());
|
||||
const actions = this.create(set, get, model);
|
||||
return { get, set, use, listen, actions, onlyView: this.onlyView };
|
||||
}
|
||||
|
||||
use(): [T, A] {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
|
||||
const { query } = useContext(Context);
|
||||
const { use, actions } = query(this);
|
||||
return [use(), actions];
|
||||
@@ -67,20 +75,27 @@ class SubModel<T, A> {
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: snapshot data is heterogeneous
|
||||
type Snapshot = [name: string, data: any];
|
||||
class Model {
|
||||
private ustack: Snapshot[][] = [];
|
||||
private rstack: Snapshot[][] = [];
|
||||
private transaction = 0;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: backup stores heterogeneous state values
|
||||
private backup = new Map<string, any>();
|
||||
public flow = {} as ReactFlowInstance<AnyWorkNode>;
|
||||
private stackListeners = new Set<() => void>();
|
||||
public readonly stackState: readonly [boolean, boolean] = [false, false];
|
||||
|
||||
constructor(
|
||||
private readonly store: Map<string, State<any, any>>,
|
||||
public readonly use: Use,
|
||||
) {}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
|
||||
private readonly store: Map<string, State<any, any>>;
|
||||
public readonly use: Use;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
|
||||
constructor(store: Map<string, State<any, any>>, use: Use) {
|
||||
this.store = store;
|
||||
this.use = use;
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this.ustack = [];
|
||||
@@ -93,12 +108,14 @@ class Model {
|
||||
public readonly listenStackState = (cb: () => void) => {
|
||||
this.stackListeners.add(cb);
|
||||
return () => this.stackListeners.delete(cb);
|
||||
}
|
||||
};
|
||||
|
||||
private triggerStackState() {
|
||||
// @ts-expect-error
|
||||
this.stackState = [this.canUndo(), this.canRedo()];
|
||||
this.stackListeners.forEach(call => call());
|
||||
for (const call of this.stackListeners) {
|
||||
call();
|
||||
}
|
||||
}
|
||||
|
||||
private getStackState = () => this.stackState;
|
||||
@@ -108,13 +125,11 @@ class Model {
|
||||
}
|
||||
|
||||
public log() {
|
||||
console.log('undo stack:', this.ustack);
|
||||
console.log('redo stack:', this.rstack);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: debug log accumulates heterogeneous values
|
||||
const snapshots: Record<string, any> = {};
|
||||
this.store.forEach((state, name) => {
|
||||
for (const [name, state] of this.store) {
|
||||
snapshots[name] = state.get();
|
||||
});
|
||||
console.log('current state:', snapshots);
|
||||
}
|
||||
}
|
||||
|
||||
public undo() {
|
||||
@@ -122,11 +137,13 @@ class Model {
|
||||
const item = ustack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
for (const [name, data] of item) {
|
||||
const entry = store.get(name);
|
||||
if (!entry) continue;
|
||||
const { get, set } = entry;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
}
|
||||
rstack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
@@ -136,11 +153,13 @@ class Model {
|
||||
const item = rstack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
for (const [name, data] of item) {
|
||||
const entry = store.get(name);
|
||||
if (!entry) continue;
|
||||
const { get, set } = entry;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
}
|
||||
ustack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
@@ -156,10 +175,10 @@ class Model {
|
||||
public startTransaction() {
|
||||
if (this.transaction === 0) {
|
||||
this.backup.clear();
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
for (const [name, state] of this.store) {
|
||||
if (state.onlyView) continue;
|
||||
this.backup.set(name, state.get());
|
||||
});
|
||||
}
|
||||
}
|
||||
this.transaction += 1;
|
||||
return this.endTransaction;
|
||||
@@ -170,24 +189,26 @@ class Model {
|
||||
this.transaction -= 1;
|
||||
if (this.transaction === 0) {
|
||||
const changes: Snapshot[] = [];
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
for (const [name, state] of this.store) {
|
||||
if (state.onlyView) continue;
|
||||
const before = this.backup.get(name);
|
||||
if (Object.is(before, state.get())) return;
|
||||
if (Object.is(before, state.get())) continue;
|
||||
changes.push([name, before]);
|
||||
});
|
||||
}
|
||||
this.backup.clear();
|
||||
if (changes.length === 0) return;
|
||||
this.ustack.push(changes);
|
||||
this.rstack.length = 0;
|
||||
this.triggerStackState();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function build() {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
|
||||
const store = new Map<string, State<any, any>>();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: memo cache stores heterogeneous values
|
||||
const mem: Record<string, any> = {};
|
||||
function use<T, A>(m: SubModel<T, A>): [T, A] {
|
||||
const state = query(m);
|
||||
@@ -195,8 +216,8 @@ function build() {
|
||||
}
|
||||
|
||||
const model = new Model(store, use);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// @ts-ignore
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// @ts-expect-error
|
||||
window.__md__ = model;
|
||||
}
|
||||
|
||||
@@ -206,9 +227,9 @@ function build() {
|
||||
const created = m.gen(model);
|
||||
store.set(m.name, created);
|
||||
return created;
|
||||
};
|
||||
}
|
||||
|
||||
return { query, model, mem, use }
|
||||
return { query, model, mem, use };
|
||||
}
|
||||
|
||||
const Context = createContext(build());
|
||||
@@ -222,24 +243,28 @@ export function RegisterFlowToContext() {
|
||||
const instance = useReactFlow<AnyWorkNode>();
|
||||
useLayoutEffect(() => {
|
||||
model.flow = instance;
|
||||
}, [instance]);
|
||||
}, [instance, model]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ModelProvider: FC<PropsWithChildren> = (p) => (
|
||||
<Context.Provider value={useMemo(build, [])}>
|
||||
{p.children}
|
||||
</Context.Provider>
|
||||
<Context.Provider value={useMemo(build, [])}>{p.children}</Context.Provider>
|
||||
);
|
||||
|
||||
function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
|
||||
return new SubModel<T, A>(name, make, create);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: default create returns setter directly
|
||||
const defaultCreate: Create<any, Setter<any>> = (set) => set;
|
||||
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>
|
||||
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>
|
||||
function defineView<T>(name: string, make: () => T, create?: any): any {
|
||||
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>;
|
||||
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>;
|
||||
function defineView<T>(
|
||||
name: string,
|
||||
make: () => T,
|
||||
create?: Create<T, unknown>,
|
||||
): SubModel<T, unknown> {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: wraps into SubModel with erased action type
|
||||
return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
|
||||
}
|
||||
|
||||
@@ -247,9 +272,12 @@ function memoize<T>(init: (use: Use, model: Model) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
|
||||
const { mem, model, use } = useContext(Context);
|
||||
const fn = mem[id] || (mem[id] = init(use, model));
|
||||
return fn as T;
|
||||
if (!mem[id]) {
|
||||
mem[id] = init(use, model);
|
||||
}
|
||||
return mem[id] as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -258,21 +286,29 @@ function compute<T>(calc: (use: UseV) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
|
||||
const { mem, query } = useContext(Context);
|
||||
let state: ReturnType<typeof generate<T>> = mem[id];
|
||||
if (state) return state.use();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: deps collect heterogeneous SubModels
|
||||
const deps = new Set<SubModel<any, any>>();
|
||||
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get());
|
||||
// biome-ignore lint/suspicious/noExplicitAny: useV erases action type
|
||||
let usev = (m: SubModel<any, any>) => {
|
||||
deps.add(m);
|
||||
return query(m).get();
|
||||
};
|
||||
mem[id] = state = generate<T>(calc(usev));
|
||||
if (deps.size) {
|
||||
usev = m => query(m).get();
|
||||
usev = (m) => query(m).get();
|
||||
const update = () => state.set(calc(usev));
|
||||
deps.forEach(m => query(m).listen(update));
|
||||
for (const m of deps) {
|
||||
query(m).listen(update);
|
||||
}
|
||||
}
|
||||
return state.use();
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const define = {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
getSmoothStepPath,
|
||||
EdgeLabelRenderer,
|
||||
useReactFlow,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
getSmoothStepPath,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { useState, useRef, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
|
||||
const SOURCE_COLOR = "#10b981";
|
||||
const TARGET_COLOR = "#3b82f6";
|
||||
@@ -38,7 +38,7 @@ function GradientPath({
|
||||
const gradientId = `gradient-${id}`;
|
||||
const showLack = hasCondition === false;
|
||||
const strokeStyle = selected
|
||||
? { stroke: '#f59e0b', strokeWidth: 2 }
|
||||
? { stroke: "#f59e0b", strokeWidth: 2 }
|
||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||
|
||||
return (
|
||||
@@ -63,13 +63,7 @@ function GradientPath({
|
||||
strokeWidth={20}
|
||||
className="react-flow__edge-interaction"
|
||||
/>
|
||||
<path
|
||||
id={id}
|
||||
d={path}
|
||||
fill="none"
|
||||
className="react-flow__edge-path"
|
||||
style={strokeStyle}
|
||||
/>
|
||||
<path id={id} d={path} fill="none" className="react-flow__edge-path" style={strokeStyle} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -143,13 +137,12 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: click handler on badge label */}
|
||||
<div onClick={handleBadgeClick} onKeyDown={undefined} className="cursor-pointer">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block px-1 bg-white rounded text-[10px]",
|
||||
condition
|
||||
? "border border-gray-300 text-black"
|
||||
: "border border-dashed text-red-500",
|
||||
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
|
||||
)}
|
||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
||||
>
|
||||
@@ -166,7 +159,6 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -183,7 +175,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
}
|
||||
|
||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
||||
const siblings = allEdges.filter(e => e.source === source && e.type === 'conditional');
|
||||
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
|
||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
||||
}
|
||||
|
||||
@@ -200,7 +192,13 @@ export function ConditionalEdge({
|
||||
data,
|
||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: RADIUS,
|
||||
});
|
||||
const flow = useReactFlow();
|
||||
const model = useModel();
|
||||
@@ -224,14 +222,20 @@ export function ConditionalEdge({
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={isElse ? null : (condition ? true : false)}
|
||||
hasCondition={isElse ? null : !!condition}
|
||||
selected={!!selected}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
{isElse
|
||||
? <ElseBadge labelX={labelX} labelY={labelY} />
|
||||
: <ConditionLabel condition={condition} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||
}
|
||||
{isElse ? (
|
||||
<ElseBadge labelX={labelX} labelY={labelY} />
|
||||
) : (
|
||||
<ConditionLabel
|
||||
condition={condition}
|
||||
labelX={labelX}
|
||||
labelY={labelY}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
@@ -248,7 +252,13 @@ export function GradientEdge({
|
||||
selected,
|
||||
}: EdgeProps<Edge>): ReactNode {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: RADIUS,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConditionalEdge, GradientEdge } from './conditional';
|
||||
import { ConditionalEdge, GradientEdge } from "./conditional";
|
||||
|
||||
export const edgeTypes = {
|
||||
conditional: ConditionalEdge,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { memo, createElement, useLayoutEffect, useEffect, createContext, useContext } from 'react';
|
||||
import { ReactFlow, ReactFlowProvider, Controls, Background, type Edge } from '@xyflow/react';
|
||||
// @ts-ignore
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { nodesModel, edgesModel, handlers, injection } from './model';
|
||||
import { ModelProvider, RegisterFlowToContext } from './context';
|
||||
import { nodeTypes } from './nodes';
|
||||
import { edgeTypes } from './edges';
|
||||
import { Dialogs, TopCenterPanel } from './panel';
|
||||
import type { AnyWorkNode } from './type';
|
||||
import { FlowModel, InternalField } from './injection';
|
||||
import { Background, Controls, type Edge, ReactFlow, ReactFlowProvider } from "@xyflow/react";
|
||||
import { createContext, createElement, memo, useContext, useEffect, useLayoutEffect } from "react";
|
||||
// @ts-expect-error
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { ModelProvider, RegisterFlowToContext } from "./context";
|
||||
import { edgeTypes } from "./edges";
|
||||
import { FlowModel, InternalField } from "./injection";
|
||||
import { edgesModel, handlers, injection, nodesModel } from "./model";
|
||||
import { nodeTypes } from "./nodes";
|
||||
import { Dialogs, TopCenterPanel } from "./panel";
|
||||
import type { AnyWorkNode } from "./type";
|
||||
|
||||
export * from './trans/type';
|
||||
export * from "./trans/type";
|
||||
|
||||
const proOptions = { hideAttribution: true };
|
||||
|
||||
@@ -20,11 +20,13 @@ export const useReadonly = () => useContext(ReadonlyContext);
|
||||
function Flow() {
|
||||
const [nodes, { onNodesChange }] = nodesModel.use();
|
||||
const [edges, { onEdgesChange, onConnect }] = edgesModel.use();
|
||||
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } = handlers.use();
|
||||
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } =
|
||||
handlers.use();
|
||||
const readonly = useReadonly();
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%' }} tabIndex={0} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: keyboard handler for flow shortcuts
|
||||
<div style={{ height: "100%" }} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow<AnyWorkNode, Edge>
|
||||
nodes={nodes}
|
||||
@@ -70,11 +72,11 @@ function Connect({ model }: { model: FlowModel }) {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return inject(instance);
|
||||
}, [instance]);
|
||||
}, [instance, inject]);
|
||||
|
||||
useEffect(() => {
|
||||
return instance.on('load', loadSteps);
|
||||
}, [instance]);
|
||||
return instance.on("load", loadSteps);
|
||||
}, [instance, loadSteps]);
|
||||
|
||||
return <MemoFlow />;
|
||||
}
|
||||
@@ -83,8 +85,6 @@ export { FlowModel };
|
||||
// biome-ignore lint/style/noDefaultExport: FlowEditor is the main public component
|
||||
export default ({ model, readonly = false }: Props) => (
|
||||
<ReadonlyContext.Provider value={readonly}>
|
||||
<ModelProvider>
|
||||
{createElement(Connect, { model })}
|
||||
</ModelProvider>
|
||||
<ModelProvider>{createElement(Connect, { model })}</ModelProvider>
|
||||
</ReadonlyContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WorkFlowSteps } from "./trans";
|
||||
import { Eventer } from './utils/eventer';
|
||||
import type { WorkFlowSteps } from "./trans";
|
||||
import { Eventer } from "./utils/eventer";
|
||||
|
||||
interface PublicEvents {
|
||||
save: WorkFlowSteps;
|
||||
@@ -9,19 +9,21 @@ interface PrivateEvents {
|
||||
load: WorkFlowSteps;
|
||||
}
|
||||
|
||||
export const InternalField = Symbol('InternalField');
|
||||
export const InternalField = Symbol("InternalField");
|
||||
|
||||
export class Injection extends Eventer<PrivateEvents> {
|
||||
constructor(
|
||||
public readonly emitPublic: Eventer<PublicEvents>['emit'],
|
||||
private inital_steps?: WorkFlowSteps,
|
||||
) {
|
||||
public readonly emitPublic: Eventer<PublicEvents>["emit"];
|
||||
private inital_steps: WorkFlowSteps | undefined;
|
||||
|
||||
constructor(emitPublic: Eventer<PublicEvents>["emit"], inital_steps?: WorkFlowSteps) {
|
||||
super();
|
||||
this.emitPublic = emitPublic;
|
||||
this.inital_steps = inital_steps;
|
||||
}
|
||||
|
||||
public on: Eventer<PrivateEvents>['on'] = (type, lisenter) => {
|
||||
const off = super.on(type, lisenter);
|
||||
if (type === 'load' && this.inital_steps) {
|
||||
public on: Eventer<PrivateEvents>["on"] = (type, lisenter) => {
|
||||
const off = super.on(type, lisenter);
|
||||
if (type === "load" && this.inital_steps) {
|
||||
lisenter(this.inital_steps);
|
||||
this.inital_steps = undefined;
|
||||
}
|
||||
@@ -37,13 +39,10 @@ export class FlowModel {
|
||||
public readonly [InternalField]: Injection;
|
||||
|
||||
constructor(inital_steps?: WorkFlowSteps) {
|
||||
this[InternalField] = new Injection(
|
||||
this.eventer.emit.bind(this.eventer),
|
||||
inital_steps,
|
||||
);
|
||||
this[InternalField] = new Injection(this.eventer.emit.bind(this.eventer), inital_steps);
|
||||
}
|
||||
|
||||
public load(steps: WorkFlowSteps) {
|
||||
this[InternalField].emit('load', steps);
|
||||
this[InternalField].emit("load", steps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LayoutLR } from "../index.js";
|
||||
|
||||
function makeNode(id: string): Node {
|
||||
return { id, type: "role", data: {}, position: { x: 0, y: 0 } } as Node;
|
||||
}
|
||||
|
||||
function makeEdge(source: string, target: string): Edge {
|
||||
return { id: `${source}-${target}`, source, target } as Edge;
|
||||
}
|
||||
|
||||
describe("LayoutLR / assignLayers", () => {
|
||||
it("1.1 Empty graph: start gets layer 0, end gets higher layer", () => {
|
||||
const nodes = [makeNode("start"), makeNode("end")];
|
||||
const result = LayoutLR(nodes, []);
|
||||
const start = result.find((n) => n.id === "start");
|
||||
const end = result.find((n) => n.id === "end");
|
||||
// start has no position change necessarily, but positions should be assigned
|
||||
expect(start).toBeDefined();
|
||||
expect(end).toBeDefined();
|
||||
// end should be to the right of start
|
||||
expect((end?.position.x ?? 0) > (start?.position.x ?? 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("1.2 Linear chain: start → A → B → end — layers assigned in order", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
|
||||
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
|
||||
expect(xOf("start") < xOf("A")).toBe(true);
|
||||
expect(xOf("A") < xOf("B")).toBe(true);
|
||||
expect(xOf("B") < xOf("end")).toBe(true);
|
||||
});
|
||||
|
||||
it("1.3 Diamond: A and B share same layer", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("C"), makeNode("end")];
|
||||
const edges = [
|
||||
makeEdge("start", "A"),
|
||||
makeEdge("start", "B"),
|
||||
makeEdge("A", "C"),
|
||||
makeEdge("B", "C"),
|
||||
makeEdge("C", "end"),
|
||||
];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
|
||||
expect(xOf("A")).toBe(xOf("B")); // same layer
|
||||
expect(xOf("A") < xOf("C")).toBe(true);
|
||||
expect(xOf("C") < xOf("end")).toBe(true);
|
||||
});
|
||||
|
||||
it("1.4 Isolated node placed in middle layer (not layer 0, not end layer)", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("isolated"), makeNode("end")];
|
||||
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
|
||||
const xIsolated = xOf("isolated");
|
||||
expect(xIsolated > xOf("start")).toBe(true);
|
||||
expect(xIsolated < xOf("end")).toBe(true);
|
||||
});
|
||||
|
||||
it("1.5 end node is always last (highest x)", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
|
||||
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const endX = result.find((n) => n.id === "end")?.position.x ?? 0;
|
||||
for (const node of result) {
|
||||
if (node.id !== "end") {
|
||||
expect(node.position.x < endX).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("1.6 start node is always first (x = 0 or smallest x)", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("end")];
|
||||
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const startX = result.find((n) => n.id === "start")?.position.x ?? 0;
|
||||
for (const node of result) {
|
||||
expect(node.position.x >= startX).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 120;
|
||||
const DEFAULT_NODE_HEIGHT = 50;
|
||||
@@ -34,8 +34,8 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
// 构建图
|
||||
for (const edge of edges) {
|
||||
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
|
||||
outgoing.get(edge.source)!.push(edge.target);
|
||||
incoming.get(edge.target)!.push(edge.source);
|
||||
outgoing.get(edge.source)?.push(edge.target);
|
||||
incoming.get(edge.target)?.push(edge.source);
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,65 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
return { outgoing, incoming, inDegree };
|
||||
}
|
||||
|
||||
function processTarget(
|
||||
target: string,
|
||||
newLayer: number,
|
||||
layers: Map<string, number>,
|
||||
inDegree: Map<string, number>,
|
||||
queue: string[],
|
||||
): void {
|
||||
const existingLayer = layers.get(target);
|
||||
if (existingLayer === undefined) {
|
||||
layers.set(target, newLayer);
|
||||
inDegree.set(target, (inDegree.get(target) ?? 1) - 1);
|
||||
if (inDegree.get(target) === 0) queue.push(target);
|
||||
} else {
|
||||
layers.set(target, Math.max(existingLayer, newLayer));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS 分层(排除 end 节点,稍后单独处理)
|
||||
*/
|
||||
function bfsLayers(
|
||||
outgoing: Map<string, string[]>,
|
||||
inDegree: Map<string, number>,
|
||||
layers: Map<string, number>,
|
||||
): void {
|
||||
const queue: string[] = ["start"];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() ?? "";
|
||||
const currentLayer = layers.get(current) ?? 0;
|
||||
for (const target of outgoing.get(current) ?? []) {
|
||||
if (target === "end") continue;
|
||||
processTarget(target, currentLayer + 1, layers, inDegree, queue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理孤立节点(没有被分配层级的非 start/end 节点),放在中间层
|
||||
*/
|
||||
function placeIsolatedNodes(nodes: Node[], layers: Map<string, number>, maxLayer: number): void {
|
||||
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
|
||||
for (const node of nodes) {
|
||||
if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) {
|
||||
layers.set(node.id, middleLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算最大层级(排除 end 节点)
|
||||
*/
|
||||
function maxLayerExcludingEnd(layers: Map<string, number>): number {
|
||||
let max = 0;
|
||||
for (const [id, layer] of layers) {
|
||||
if (id !== "end") max = Math.max(max, layer);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用拓扑排序将节点分层
|
||||
* - 'start' 节点固定在第 0 层
|
||||
@@ -52,62 +111,15 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||
const { outgoing, inDegree } = buildGraph(nodes, edges);
|
||||
const layers = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
|
||||
// 1. start 节点固定在第 0 层
|
||||
layers.set('start', 0);
|
||||
queue.push('start');
|
||||
layers.set("start", 0);
|
||||
bfsLayers(outgoing, inDegree, layers);
|
||||
|
||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const currentLayer = layers.get(current)!;
|
||||
const afterBfsMax = maxLayerExcludingEnd(layers);
|
||||
placeIsolatedNodes(nodes, layers, afterBfsMax);
|
||||
|
||||
for (const target of outgoing.get(current) ?? []) {
|
||||
// 跳过 end 节点,稍后处理
|
||||
if (target === 'end') continue;
|
||||
|
||||
const newLayer = currentLayer + 1;
|
||||
const existingLayer = layers.get(target);
|
||||
|
||||
if (existingLayer === undefined) {
|
||||
layers.set(target, newLayer);
|
||||
inDegree.set(target, (inDegree.get(target) ?? 1) - 1);
|
||||
if (inDegree.get(target) === 0) {
|
||||
queue.push(target);
|
||||
}
|
||||
} else {
|
||||
// 如果已有层级,取更大的值(确保所有前驱都在前面)
|
||||
layers.set(target, Math.max(existingLayer, newLayer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 找到当前最大层级
|
||||
let maxLayer = 0;
|
||||
for (const layer of layers.values()) {
|
||||
maxLayer = Math.max(maxLayer, layer);
|
||||
}
|
||||
|
||||
// 4. 处理孤立节点(没有被分配层级的非 start/end 节点)
|
||||
// 把它们放在中间层
|
||||
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
|
||||
for (const node of nodes) {
|
||||
if (node.id !== 'start' && node.id !== 'end' && !layers.has(node.id)) {
|
||||
layers.set(node.id, middleLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 重新计算最大层级(可能因为孤立节点而变化)
|
||||
maxLayer = 0;
|
||||
for (const [id, layer] of layers) {
|
||||
if (id !== 'end') {
|
||||
maxLayer = Math.max(maxLayer, layer);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. end 节点固定在最后一层
|
||||
layers.set('end', maxLayer + 1);
|
||||
const finalMax = maxLayerExcludingEnd(layers);
|
||||
layers.set("end", finalMax + 1);
|
||||
|
||||
return layers;
|
||||
}
|
||||
@@ -123,7 +135,7 @@ function groupByLayer<N extends Node>(nodes: N[], layers: Map<string, number>):
|
||||
if (!groups.has(layer)) {
|
||||
groups.set(layer, []);
|
||||
}
|
||||
groups.get(layer)!.push(node);
|
||||
groups.get(layer)?.push(node);
|
||||
}
|
||||
|
||||
return groups;
|
||||
@@ -152,7 +164,7 @@ function calculateLayerWidths(layerGroups: Map<number, Node[]>): Map<number, num
|
||||
*/
|
||||
function calculateLayerXPositions(
|
||||
layerWidths: Map<number, number>,
|
||||
maxLayer: number
|
||||
maxLayer: number,
|
||||
): Map<number, number> {
|
||||
const xPositions = new Map<number, number>();
|
||||
let currentX = 0;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Edge } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import { edgesModel } from './edges';
|
||||
import type { RoleNodeData, AnyWorkNode } from '../type';
|
||||
import type { Edge } from "@xyflow/react";
|
||||
import { define } from "../context";
|
||||
import type { AnyWorkNode, RoleNodeData } from "../type";
|
||||
import { edgesModel } from "./edges";
|
||||
import { nodesModel } from "./nodes";
|
||||
|
||||
type ConnectHandle = {
|
||||
id?: string | null;
|
||||
nodeId: string;
|
||||
type: 'source' | 'target';
|
||||
type: "source" | "target";
|
||||
};
|
||||
|
||||
export type AddNodeState = {
|
||||
@@ -21,10 +21,10 @@ type CommitParams = {
|
||||
};
|
||||
|
||||
function addNodeView() {
|
||||
return null as (AddNodeState | null);
|
||||
return null as AddNodeState | null;
|
||||
}
|
||||
|
||||
export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, get, model) => {
|
||||
export const addNodeViewModel = define.view("addNodeView", addNodeView, (set, get, model) => {
|
||||
function start(state: AddNodeState) {
|
||||
set(state);
|
||||
}
|
||||
@@ -42,12 +42,19 @@ export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, ge
|
||||
const { data } = params;
|
||||
|
||||
const id = `n${Date.now()}`;
|
||||
const node = { id, data, position, type: 'role' as const, origin: [0.0, 0.5] as [number, number] };
|
||||
const node = {
|
||||
id,
|
||||
data,
|
||||
position,
|
||||
type: "role" as const,
|
||||
origin: [0.0, 0.5] as [number, number],
|
||||
};
|
||||
|
||||
const [fnid, fhid] = [fromNode.id, fromHandle.id];
|
||||
const newEdge: Edge = fromHandle.type === 'source'
|
||||
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
|
||||
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
|
||||
const newEdge: Edge =
|
||||
fromHandle.type === "source"
|
||||
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
|
||||
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
|
||||
|
||||
model.startTransaction();
|
||||
model.use(nodesModel)[1].set((nds) => nds.concat(node));
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import {
|
||||
applyEdgeChanges,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
} from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { applyEdgeChanges, type Connection, type Edge, type EdgeChange } from "@xyflow/react";
|
||||
import { define } from "../context";
|
||||
|
||||
function makeEdges(): Edge[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function isInputHandle(handle: string | null | undefined): boolean {
|
||||
return handle === 'input' || handle === 'input-top' || handle === 'input-bottom';
|
||||
return handle === "input" || handle === "input-top" || handle === "input-bottom";
|
||||
}
|
||||
|
||||
function isOutputHandle(handle: string | null | undefined): boolean {
|
||||
return handle === 'output' || handle === 'output-top' || handle === 'output-bottom';
|
||||
return handle === "output" || handle === "output-top" || handle === "output-bottom";
|
||||
}
|
||||
|
||||
function normalizeConnection(params: Edge | Connection): Edge | Connection {
|
||||
@@ -33,10 +28,10 @@ function normalizeConnection(params: Edge | Connection): Edge | Connection {
|
||||
|
||||
let edgeCounter = 0;
|
||||
|
||||
export const edgesModel = define.model('edges', makeEdges, (set, get, model) => {
|
||||
export const edgesModel = define.model("edges", makeEdges, (set, get, model) => {
|
||||
function onEdgesChange(changes: EdgeChange[]) {
|
||||
const whites = new Set(['add', 'replace']);
|
||||
if (changes.some(c => whites.has(c.type))) {
|
||||
const whites = new Set(["add", "replace"]);
|
||||
if (changes.some((c) => whites.has(c.type))) {
|
||||
model.startTransaction();
|
||||
set((eds) => applyEdgeChanges(changes, eds));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
@@ -54,7 +49,7 @@ export const edgesModel = define.model('edges', makeEdges, (set, get, model) =>
|
||||
|
||||
const currentEdges = get();
|
||||
const duplicate = currentEdges.some(
|
||||
e => e.source === normalized.source && e.target === normalized.target,
|
||||
(e) => e.source === normalized.source && e.target === normalized.target,
|
||||
);
|
||||
if (duplicate) return;
|
||||
|
||||
@@ -67,15 +62,15 @@ export const edgesModel = define.model('edges', makeEdges, (set, get, model) =>
|
||||
animated: true,
|
||||
} as Edge;
|
||||
|
||||
const existingFromSource = currentEdges.filter(e => e.source === normalized.source);
|
||||
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
||||
|
||||
if (existingFromSource.length > 0) {
|
||||
edge.type = 'conditional';
|
||||
edge.data = { condition: '' };
|
||||
edge.type = "conditional";
|
||||
edge.data = { condition: "" };
|
||||
|
||||
const promoted = currentEdges.map(e => {
|
||||
if (e.source === normalized.source && e.type !== 'conditional') {
|
||||
return { ...e, type: 'conditional' as const, data: { condition: '' } };
|
||||
const promoted = currentEdges.map((e) => {
|
||||
if (e.source === normalized.source && e.type !== "conditional") {
|
||||
return { ...e, type: "conditional" as const, data: { condition: "" } };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import type { RoleNodeData, WorkNode } from '../type';
|
||||
import { define } from "../context";
|
||||
import type { RoleNodeData, WorkNode } from "../type";
|
||||
import { nodesModel } from "./nodes";
|
||||
|
||||
export type EditNodeState = {
|
||||
node: WorkNode<'role'>;
|
||||
node: WorkNode<"role">;
|
||||
};
|
||||
|
||||
function editNodeView() {
|
||||
return null as (EditNodeState | null);
|
||||
return null as EditNodeState | null;
|
||||
}
|
||||
|
||||
export const editNodeViewModel = define.view('editNodeView', editNodeView, (set, get, model) => {
|
||||
export const editNodeViewModel = define.view("editNodeView", editNodeView, (set, get, model) => {
|
||||
function start(nodeId: string) {
|
||||
const [nodes] = model.use(nodesModel);
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node || node.type !== 'role') return;
|
||||
set({ node: node as WorkNode<'role'> });
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.type !== "role") return;
|
||||
set({ node: node as WorkNode<"role"> });
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
@@ -31,6 +31,7 @@ export const editNodeViewModel = define.view('editNodeView', editNodeView, (set,
|
||||
|
||||
model.startTransaction();
|
||||
editNode(state.node.id, (node) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: node data type varies by node kind
|
||||
node.data = data as any;
|
||||
});
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { OnNodeDrag, OnConnectEnd, OnBeforeDelete, OnDelete } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { addNodeViewModel } from './add-node-view';
|
||||
import type { AnyWorkNode } from '../type';
|
||||
import { LayoutLR } from '../layout';
|
||||
import { nodesModel } from './nodes';
|
||||
import { edgesModel } from './edges';
|
||||
import { injection } from './inject';
|
||||
import { transIn, transOut, validate } from '../trans';
|
||||
import type { WorkFlowSteps } from '../trans';
|
||||
import { editNodeViewModel } from './edit-node-view';
|
||||
import type { OnBeforeDelete, OnConnectEnd, OnDelete, OnNodeDrag } from "@xyflow/react";
|
||||
import { define } from "../context";
|
||||
import { LayoutLR } from "../layout";
|
||||
import type { WorkFlowSteps } from "../trans";
|
||||
import { transIn, transOut, validate } from "../trans";
|
||||
import type { AnyWorkNode } from "../type";
|
||||
import { addNodeViewModel } from "./add-node-view";
|
||||
import { edgesModel } from "./edges";
|
||||
import { editNodeViewModel } from "./edit-node-view";
|
||||
import { injection } from "./inject";
|
||||
import { nodesModel } from "./nodes";
|
||||
|
||||
export const handlers = define.memoize((use, model) => {
|
||||
const onNodeDragStart: OnNodeDrag<AnyWorkNode> = () => {
|
||||
@@ -23,27 +23,31 @@ export const handlers = define.memoize((use, model) => {
|
||||
if (!to || !fromHandle || !fromNode) return;
|
||||
const { clientX, clientY } = event as MouseEvent;
|
||||
use(addNodeViewModel)[1].start({
|
||||
// biome-ignore lint/suspicious/noExplicitAny: ReactFlow node type mismatch
|
||||
fromNode: fromNode as any as AnyWorkNode,
|
||||
fromHandle: fromHandle,
|
||||
position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }),
|
||||
});
|
||||
};
|
||||
|
||||
function isProtectedNode(node: AnyWorkNode): boolean {
|
||||
return node.type === "start" || node.type === "end";
|
||||
}
|
||||
|
||||
function isFirstConditionalSibling(
|
||||
edge: { id: string; source: string; type: string | null },
|
||||
allEdges: { id: string; source: string; type: string | null }[],
|
||||
): boolean {
|
||||
if (edge.type !== "conditional") return false;
|
||||
const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional");
|
||||
return siblings.length >= 2 && siblings[0].id === edge.id;
|
||||
}
|
||||
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'start' || node.type === 'end') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (nodes.some(isProtectedNode)) return false;
|
||||
if (edges.length > 0) {
|
||||
const allEdges = use(edgesModel)[0];
|
||||
for (const edge of edges) {
|
||||
if (edge.type !== 'conditional') continue;
|
||||
const siblings = allEdges.filter(e => e.source === edge.source && e.type === 'conditional');
|
||||
if (siblings.length >= 2 && siblings[0].id === edge.id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
|
||||
}
|
||||
model.startTransaction();
|
||||
return true;
|
||||
@@ -52,20 +56,20 @@ export const handlers = define.memoize((use, model) => {
|
||||
if (deletedEdges.length > 0) {
|
||||
const currentEdges = use(edgesModel)[0];
|
||||
const sourcesToCheck = new Set(
|
||||
deletedEdges
|
||||
.filter(e => e.type === 'conditional')
|
||||
.map(e => e.source),
|
||||
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
|
||||
);
|
||||
|
||||
if (sourcesToCheck.size > 0) {
|
||||
let needsDowngrade = false;
|
||||
const updatedEdges = currentEdges.map(e => {
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== 'conditional') return e;
|
||||
const siblings = currentEdges.filter(s => s.source === e.source && s.type === 'conditional');
|
||||
const updatedEdges = currentEdges.map((e) => {
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
|
||||
const siblings = currentEdges.filter(
|
||||
(s) => s.source === e.source && s.type === "conditional",
|
||||
);
|
||||
if (siblings.length === 1) {
|
||||
needsDowngrade = true;
|
||||
const { data: _, ...rest } = e;
|
||||
return { ...rest, type: 'default' as const };
|
||||
return { ...rest, type: "default" as const };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
@@ -93,25 +97,28 @@ export const handlers = define.memoize((use, model) => {
|
||||
use(editNodeViewModel)[1].cancel();
|
||||
}
|
||||
|
||||
function handleEscape() {
|
||||
const [addView, addViewActions] = use(addNodeViewModel);
|
||||
const [editView, editViewActions] = use(editNodeViewModel);
|
||||
if (addView) addViewActions.cancel();
|
||||
if (editView) editViewActions.cancel();
|
||||
}
|
||||
|
||||
function handleUndoRedo(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.code === "KeyZ" && (event.ctrlKey || event.metaKey)) {
|
||||
if (event.shiftKey) model.redo();
|
||||
else model.undo();
|
||||
} else if (event.code === "KeyY" && (event.ctrlKey || event.metaKey)) {
|
||||
model.redo();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.code === 'Escape') {
|
||||
const [addView, addViewActions] = use(addNodeViewModel);
|
||||
const [editView, editViewActions] = use(editNodeViewModel);
|
||||
if (addView) addViewActions.cancel();
|
||||
if (editView) editViewActions.cancel();
|
||||
if (event.code === "Escape") {
|
||||
handleEscape();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 'KeyZ') {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.shiftKey) model.redo();
|
||||
else model.undo();
|
||||
}
|
||||
} else if (event.code === 'KeyY') {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
model.redo();
|
||||
}
|
||||
}
|
||||
handleUndoRedo(event);
|
||||
}
|
||||
|
||||
function loadSteps(steps: WorkFlowSteps) {
|
||||
@@ -130,7 +137,7 @@ export const handlers = define.memoize((use, model) => {
|
||||
if (result.valid) {
|
||||
const steps = transOut(nodes, edges);
|
||||
const instance = use(injection)[0];
|
||||
instance.emitPublic('save', steps);
|
||||
instance.emitPublic("save", steps);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { nodesModel } from './nodes';
|
||||
export { edgesModel } from './edges';
|
||||
export { addNodeViewModel, type AddNodeState } from './add-node-view';
|
||||
export { editNodeViewModel, type EditNodeState } from './edit-node-view';
|
||||
export { handlers } from './handlers';
|
||||
export { injection } from './inject';
|
||||
export { type AddNodeState, addNodeViewModel } from "./add-node-view";
|
||||
export { edgesModel } from "./edges";
|
||||
export { type EditNodeState, editNodeViewModel } from "./edit-node-view";
|
||||
export { handlers } from "./handlers";
|
||||
export { injection } from "./inject";
|
||||
export { nodesModel } from "./nodes";
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import { define } from "../context.tsx";
|
||||
import { Injection } from '../injection.ts';
|
||||
|
||||
import { Injection } from "../injection.ts";
|
||||
|
||||
const NOOP = () => {};
|
||||
const placeholder = new Injection(NOOP);
|
||||
@@ -13,7 +12,7 @@ function make(): Injection {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
export const injection = define.view('injection', make, (set) => {
|
||||
export const injection = define.view("injection", make, (set) => {
|
||||
function reset() {
|
||||
set(make());
|
||||
}
|
||||
|
||||
@@ -1,48 +1,49 @@
|
||||
import { produce, type Draft } from 'immer';
|
||||
import { applyNodeChanges, NodeChange } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import type { AnyWorkNode } from '../type';
|
||||
|
||||
import { applyNodeChanges, type NodeChange } from "@xyflow/react";
|
||||
import { type Draft, produce } from "immer";
|
||||
import { define } from "../context";
|
||||
import type { AnyWorkNode } from "../type";
|
||||
|
||||
function makeNodes(): AnyWorkNode[] {
|
||||
return [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
data: { label: 'Start' },
|
||||
id: "start",
|
||||
type: "start",
|
||||
data: { label: "Start" },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
data: { label: 'End' },
|
||||
id: "end",
|
||||
data: { label: "End" },
|
||||
position: { x: 1000, y: 0 },
|
||||
type: 'end',
|
||||
type: "end",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const nodesModel = define.model('nodes', makeNodes, (set, _get, model) => {
|
||||
const whites = new Set<NodeChange['type']>(['add', 'replace']);
|
||||
export const nodesModel = define.model("nodes", makeNodes, (set, _get, model) => {
|
||||
const whites = new Set<NodeChange["type"]>(["add", "replace"]);
|
||||
function onNodesChange(changes: NodeChange<AnyWorkNode>[]) {
|
||||
if (changes.some(c => whites.has(c.type))) {
|
||||
if (changes.some((c) => whites.has(c.type))) {
|
||||
model.startTransaction();
|
||||
set((nds) => applyNodeChanges(changes, nds));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
return;
|
||||
}
|
||||
set((nds) => applyNodeChanges(changes, nds));
|
||||
};
|
||||
}
|
||||
|
||||
function editNode(id: string, updater: (node: Draft<AnyWorkNode>) => void) {
|
||||
set(produce((draft) => {
|
||||
const node = draft.find(n => n.id === id);
|
||||
if (node) updater(node);
|
||||
}));
|
||||
set(
|
||||
produce((draft) => {
|
||||
const node = draft.find((n) => n.id === id);
|
||||
if (node) updater(node);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteNode(id: string) {
|
||||
model.startTransaction();
|
||||
set((nds) => nds.filter(n => n.id !== id));
|
||||
set((nds) => nds.filter((n) => n.id !== id));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { Handle, Position, Node, NodeProps } from '@xyflow/react';
|
||||
import { EndNode } from './nodes.style';
|
||||
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
|
||||
import { EndNode } from "./nodes.style";
|
||||
|
||||
interface NodeData {
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type NodeType = Node<NodeData, 'end'>;
|
||||
type NodeType = Node<NodeData, "end">;
|
||||
type Props = NodeProps<NodeType>;
|
||||
|
||||
export function NodeEnd({ data }: Props) {
|
||||
return (
|
||||
<EndNode>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="input"
|
||||
/>
|
||||
{data?.label || 'End'}
|
||||
<Handle type="target" position={Position.Left} id="input" />
|
||||
{data?.label || "End"}
|
||||
</EndNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NodeStart } from './start';
|
||||
import { NodeEnd } from './end';
|
||||
import { NodeRole } from './role';
|
||||
import { NodeEnd } from "./end";
|
||||
import { NodeRole } from "./role";
|
||||
import { NodeStart } from "./start";
|
||||
|
||||
export const nodeTypes = {
|
||||
start: NodeStart,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
|
||||
type Props = {
|
||||
@@ -13,7 +13,13 @@ export function NodeToolbarActions({ onEdit, onDelete }: Props): ReactNode {
|
||||
<Button variant="ghost" size="icon-xs" onClick={onEdit} title="编辑">
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" className="hover:bg-destructive/10 hover:text-destructive" onClick={onDelete} title="删除">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -45,12 +45,7 @@ export function NodeContent({ children }: { children: ReactNode }): ReactNode {
|
||||
|
||||
export function NodeIcon({ className, children }: Props): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-lg shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center justify-center w-8 h-8 rounded-lg shrink-0", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -62,23 +57,14 @@ export function NodeBody({ children }: { children: ReactNode }): ReactNode {
|
||||
|
||||
export function NodeKindLabel({ className, children }: Props): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-[10px] font-semibold uppercase tracking-wide mb-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("text-[10px] font-semibold uppercase tracking-wide mb-1", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeHint({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="text-[13px] text-gray-800 leading-snug break-words">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return <div className="text-[13px] text-gray-800 leading-snug break-words">{children}</div>;
|
||||
}
|
||||
|
||||
export function NodeSubHint({ children }: { children: ReactNode }): ReactNode {
|
||||
@@ -93,8 +79,6 @@ export function RoleIcon({ children }: { children: ReactNode }): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleKindLabel({
|
||||
children,
|
||||
}: { children: ReactNode }): ReactNode {
|
||||
export function RoleKindLabel({ children }: { children: ReactNode }): ReactNode {
|
||||
return <NodeKindLabel className="text-teal-700">{children}</NodeKindLabel>;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { Handle, Position, NodeToolbar, useNodeConnections, type NodeProps } from '@xyflow/react';
|
||||
import { Users } from 'lucide-react';
|
||||
import {
|
||||
NodeContent,
|
||||
NodeBody,
|
||||
RoleIcon,
|
||||
RoleKindLabel,
|
||||
NodeHint,
|
||||
} from './nodes.style';
|
||||
import { NodeToolbarActions } from './node-toolbar';
|
||||
import { editNodeViewModel } from '../model/edit-node-view';
|
||||
import { nodesModel } from '../model';
|
||||
import type { WorkNode } from '../type';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { useReadonly } from '../flow';
|
||||
import { Handle, type NodeProps, NodeToolbar, Position, useNodeConnections } from "@xyflow/react";
|
||||
import { Users } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useReadonly } from "../flow";
|
||||
import { nodesModel } from "../model";
|
||||
import { editNodeViewModel } from "../model/edit-node-view";
|
||||
import type { WorkNode } from "../type";
|
||||
import { NodeToolbarActions } from "./node-toolbar";
|
||||
import { NodeBody, NodeContent, NodeHint, RoleIcon, RoleKindLabel } from "./nodes.style";
|
||||
|
||||
type Props = NodeProps<WorkNode<'role'>>;
|
||||
type Props = NodeProps<WorkNode<"role">>;
|
||||
|
||||
const containerClass = "bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150";
|
||||
const containerClass =
|
||||
"bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150";
|
||||
const targetClass = "!bg-blue-100 !border-blue-500 hover:!bg-blue-500 hover:!border-blue-600";
|
||||
const sourceClass = "!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600";
|
||||
const sourceClass =
|
||||
"!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600";
|
||||
|
||||
export function NodeRole({ data, id, selected }: Props) {
|
||||
const startEdit = editNodeViewModel.useCreation().start;
|
||||
@@ -35,8 +31,14 @@ export function NodeRole({ data, id, selected }: Props) {
|
||||
return set;
|
||||
}, [connections, id]);
|
||||
|
||||
const hasInputConnection = connectedHandles.has('input') || connectedHandles.has('input-top') || connectedHandles.has('input-bottom');
|
||||
const hasOutputConnection = connectedHandles.has('output') || connectedHandles.has('output-top') || connectedHandles.has('output-bottom');
|
||||
const hasInputConnection =
|
||||
connectedHandles.has("input") ||
|
||||
connectedHandles.has("input-top") ||
|
||||
connectedHandles.has("input-bottom");
|
||||
const hasOutputConnection =
|
||||
connectedHandles.has("output") ||
|
||||
connectedHandles.has("output-top") ||
|
||||
connectedHandles.has("output-bottom");
|
||||
|
||||
const showHandle = (handleId: string, alwaysShow: boolean) => {
|
||||
if (readonly) return connectedHandles.has(handleId);
|
||||
@@ -45,9 +47,35 @@ export function NodeRole({ data, id, selected }: Props) {
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{showHandle('input', true) && <Handle type="target" position={Position.Left} id="input" className={targetClass} isConnectableStart />}
|
||||
{showHandle('input-top', hasInputConnection) && <Handle type="target" position={Position.Top} id="input-top" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
|
||||
{showHandle('input-bottom', hasInputConnection) && <Handle type="target" position={Position.Bottom} id="input-bottom" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
|
||||
{showHandle("input", true) && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="input"
|
||||
className={targetClass}
|
||||
isConnectableStart
|
||||
/>
|
||||
)}
|
||||
{showHandle("input-top", hasInputConnection) && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="input-top"
|
||||
style={{ left: "30%" }}
|
||||
className={targetClass}
|
||||
isConnectableStart
|
||||
/>
|
||||
)}
|
||||
{showHandle("input-bottom", hasInputConnection) && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
id="input-bottom"
|
||||
style={{ left: "30%" }}
|
||||
className={targetClass}
|
||||
isConnectableStart
|
||||
/>
|
||||
)}
|
||||
<NodeContent>
|
||||
<RoleIcon>
|
||||
<Users size={16} />
|
||||
@@ -58,14 +86,37 @@ export function NodeRole({ data, id, selected }: Props) {
|
||||
</NodeBody>
|
||||
</NodeContent>
|
||||
<NodeToolbar isVisible={selected && !readonly} position={Position.Bottom}>
|
||||
<NodeToolbarActions
|
||||
onEdit={() => startEdit(id)}
|
||||
onDelete={() => deleteNode(id)}
|
||||
/>
|
||||
<NodeToolbarActions onEdit={() => startEdit(id)} onDelete={() => deleteNode(id)} />
|
||||
</NodeToolbar>
|
||||
{showHandle('output', true) && <Handle type="source" position={Position.Right} id="output" className={sourceClass} isConnectableEnd />}
|
||||
{showHandle('output-top', hasOutputConnection) && <Handle type="source" position={Position.Top} id="output-top" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
|
||||
{showHandle('output-bottom', hasOutputConnection) && <Handle type="source" position={Position.Bottom} id="output-bottom" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
|
||||
{showHandle("output", true) && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="output"
|
||||
className={sourceClass}
|
||||
isConnectableEnd
|
||||
/>
|
||||
)}
|
||||
{showHandle("output-top", hasOutputConnection) && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Top}
|
||||
id="output-top"
|
||||
style={{ left: "70%" }}
|
||||
className={sourceClass}
|
||||
isConnectableEnd
|
||||
/>
|
||||
)}
|
||||
{showHandle("output-bottom", hasOutputConnection) && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="output-bottom"
|
||||
style={{ left: "70%" }}
|
||||
className={sourceClass}
|
||||
isConnectableEnd
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Handle, Position, Node, NodeProps, useNodeConnections } from '@xyflow/react';
|
||||
import { StartNode } from './nodes.style';
|
||||
import { useMemo } from 'react';
|
||||
import { Handle, type Node, type NodeProps, Position, useNodeConnections } from "@xyflow/react";
|
||||
import { useMemo } from "react";
|
||||
import { StartNode } from "./nodes.style";
|
||||
|
||||
interface NodeData {
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type NodeType = Node<NodeData, 'start'>;
|
||||
type NodeType = Node<NodeData, "start">;
|
||||
type Props = NodeProps<NodeType>;
|
||||
|
||||
export function NodeStart({ data, id }: Props) {
|
||||
@@ -19,7 +19,7 @@ export function NodeStart({ data, id }: Props) {
|
||||
|
||||
return (
|
||||
<StartNode>
|
||||
{data?.label || 'Start'}
|
||||
{data?.label || "Start"}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { addNodeViewModel, type AddNodeState } from "../model/index.ts";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "../../components/ui/dialog.tsx";
|
||||
import { Input } from "../../components/ui/input.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import { Label } from "../../components/ui/label.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { addNodeViewModel } from "../model/index.ts";
|
||||
import type { RoleNodeData } from "../type.ts";
|
||||
|
||||
type FormProps = {
|
||||
state: AddNodeState;
|
||||
onSubmit: (params: { data: RoleNodeData }) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
function Form({ onSubmit, onCancel }: FormProps): ReactNode {
|
||||
const [name, setName] = useState("新角色");
|
||||
const [description, setDescription] = useState("");
|
||||
const [identity, setIdentity] = useState("");
|
||||
@@ -34,7 +33,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
setPrepare("");
|
||||
setExecute("");
|
||||
setReport("");
|
||||
}, [state]);
|
||||
}, []);
|
||||
|
||||
function handleConfirm() {
|
||||
if (!name.trim()) return;
|
||||
@@ -59,11 +58,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">名称 *</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="角色名称"
|
||||
/>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="角色名称" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">描述</Label>
|
||||
@@ -136,10 +131,12 @@ export function AddNodeDialog(): ReactNode {
|
||||
return (
|
||||
<Dialog
|
||||
open={state !== null}
|
||||
onOpenChange={(open) => { if (!open) cancel(); }}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) cancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
||||
{state && <Form onSubmit={commit} onCancel={cancel} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import {
|
||||
editNodeViewModel,
|
||||
type EditNodeState,
|
||||
} from "../model/edit-node-view.ts";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "../../components/ui/dialog.tsx";
|
||||
import { Input } from "../../components/ui/input.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import { Label } from "../../components/ui/label.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { type EditNodeState, editNodeViewModel } from "../model/edit-node-view.ts";
|
||||
import type { RoleNodeData } from "../type.ts";
|
||||
|
||||
type FormProps = {
|
||||
@@ -61,11 +58,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">名称 *</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="角色名称"
|
||||
/>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="角色名称" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">描述</Label>
|
||||
@@ -138,7 +131,9 @@ export function EditNodeDialog(): ReactNode {
|
||||
return (
|
||||
<Dialog
|
||||
open={state !== null}
|
||||
onOpenChange={(open) => { if (!open) cancel(); }}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) cancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Panel } from '@xyflow/react';
|
||||
import { AddNodeDialog } from './add-node';
|
||||
import { EditNodeDialog } from './edit-node';
|
||||
import { Toolbar } from './toolbar';
|
||||
|
||||
import { Panel } from "@xyflow/react";
|
||||
import { AddNodeDialog } from "./add-node";
|
||||
import { EditNodeDialog } from "./edit-node";
|
||||
import { Toolbar } from "./toolbar";
|
||||
|
||||
export function Dialogs() {
|
||||
return (
|
||||
@@ -13,7 +12,6 @@ export function Dialogs() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function TopCenterPanel() {
|
||||
return (
|
||||
<Panel position="top-center">
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
Users,
|
||||
LayoutList,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useReactFlow, useStoreApi } from "@xyflow/react";
|
||||
import { LayoutList, Redo2, Save, Undo2, Users } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import { Separator } from "../../components/ui/separator.tsx";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import { handlers, nodesModel } from "../model/index.ts";
|
||||
import { Separator } from "../../components/ui/separator.tsx";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import type { RoleNodeData, WorkNode } from "../type.ts";
|
||||
import { uuid } from "../utils/index.ts";
|
||||
import { useState } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const DEFAULT_ROLE_DATA: RoleNodeData = {
|
||||
name: '新角色',
|
||||
description: '',
|
||||
identity: '',
|
||||
prepare: '',
|
||||
execute: '',
|
||||
report: '',
|
||||
name: "新角色",
|
||||
description: "",
|
||||
identity: "",
|
||||
prepare: "",
|
||||
execute: "",
|
||||
report: "",
|
||||
};
|
||||
|
||||
export function Toolbar(): ReactNode {
|
||||
@@ -48,9 +41,9 @@ export function Toolbar(): ReactNode {
|
||||
const centerY = (height / 2 - y) / zoom;
|
||||
|
||||
const id = `n${uuid()}`;
|
||||
const node: WorkNode<'role'> = {
|
||||
const node: WorkNode<"role"> = {
|
||||
id,
|
||||
type: 'role',
|
||||
type: "role",
|
||||
position: { x: centerX - 80, y: centerY - 40 },
|
||||
data: { ...DEFAULT_ROLE_DATA },
|
||||
};
|
||||
@@ -63,10 +56,22 @@ export function Toolbar(): ReactNode {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-[10px] shadow-md">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon-sm" title="撤销 (Undo)" onClick={handleUndo} disabled={!canUndo}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="撤销 (Undo)"
|
||||
onClick={handleUndo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
<Undo2 />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" title="重做 (Redo)" onClick={handleRedo} disabled={!canRedo}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="重做 (Redo)"
|
||||
onClick={handleRedo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
<Redo2 />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -101,14 +106,12 @@ function SaveButton(): ReactNode {
|
||||
if (valid) {
|
||||
setToast({ open: true, severity: "success", message: "流程保存成功" });
|
||||
} else {
|
||||
const errorMessages = errors.map(
|
||||
({ message, nodeId }) => (
|
||||
<div key={nodeId ?? message}>
|
||||
{nodeId ? `节点 ${nodeId}:` : ""}
|
||||
{message}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
const errorMessages = errors.map(({ message, nodeId }) => (
|
||||
<div key={nodeId ?? message}>
|
||||
{nodeId ? `节点 ${nodeId}:` : ""}
|
||||
{message}
|
||||
</div>
|
||||
));
|
||||
setToast({
|
||||
open: true,
|
||||
severity: "error",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transIn } from "../trans-in.js";
|
||||
import type { WorkFlowStep } from "../type.js";
|
||||
|
||||
function makeStep(name: string, transitions: WorkFlowStep["transitions"]): WorkFlowStep {
|
||||
return {
|
||||
role: {
|
||||
name,
|
||||
description: "",
|
||||
identity: "",
|
||||
prepare: "",
|
||||
execute: "",
|
||||
report: "",
|
||||
},
|
||||
transitions,
|
||||
};
|
||||
}
|
||||
|
||||
describe("transIn", () => {
|
||||
it("4.1 Empty steps → start + end nodes, no edges", () => {
|
||||
const { nodes, edges } = transIn([]);
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(nodes.find((n) => n.id === "start")).toBeDefined();
|
||||
expect(nodes.find((n) => n.id === "end")).toBeDefined();
|
||||
expect(edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("4.2 Single step with no END transition → start→role edge exists", () => {
|
||||
const steps = [makeStep("A", [])];
|
||||
const { nodes, edges } = transIn(steps);
|
||||
expect(nodes).toHaveLength(3); // start, end, role-A
|
||||
const startEdge = edges.find((e) => e.source === "start");
|
||||
expect(startEdge).toBeDefined();
|
||||
const roleNode = nodes.find((n) => n.type === "role");
|
||||
expect(startEdge?.target).toBe(roleNode?.id);
|
||||
});
|
||||
|
||||
it("4.3 Single step with END transition → edge to end node exists", () => {
|
||||
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
|
||||
const { edges } = transIn(steps);
|
||||
const endEdge = edges.find((e) => e.target === "end");
|
||||
expect(endEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it("4.4 Two steps with default transitions chain", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, target: "END" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
// Should have start→A, A→B, B→end
|
||||
expect(edges.find((e) => e.source === "start")).toBeDefined();
|
||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
|
||||
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
||||
// No conditional edges
|
||||
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
|
||||
});
|
||||
|
||||
it("4.5 Step with multiple transitions → conditional edges", () => {
|
||||
const steps = [
|
||||
makeStep("A", [
|
||||
{ condition: null, target: "B" },
|
||||
{ condition: "x>0", 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);
|
||||
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
|
||||
// else-branch has empty condition
|
||||
const elseEdge = outEdges.find(
|
||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
|
||||
);
|
||||
expect(elseEdge).toBeDefined();
|
||||
// if-branch has condition
|
||||
const ifEdge = outEdges.find(
|
||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
|
||||
);
|
||||
expect(ifEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "END" }]),
|
||||
makeStep("B", [{ condition: null, target: "END" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
// start→A and start→B; end has 2 incoming edges
|
||||
const incomingToEnd = edges.filter((e) => e.target === "end");
|
||||
expect(incomingToEnd[0].targetHandle).toBe("input");
|
||||
});
|
||||
|
||||
it("4.7 Same role name maps to same node id across steps", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, target: "A" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
const aId = edges.find((e) => e.source === "start")?.target;
|
||||
// B→A edge target should be same node as start→A edge target
|
||||
const bToAEdge = edges.find(
|
||||
(e) => e.source !== "start" && e.target === aId && e.target !== "end",
|
||||
);
|
||||
expect(bToAEdge).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AnyWorkEdge, AnyWorkNode } from "../../type.js";
|
||||
import { validate } from "../validate.js";
|
||||
|
||||
function roleNode(id: string): AnyWorkNode {
|
||||
return {
|
||||
id,
|
||||
type: "role",
|
||||
data: { name: id, description: "", identity: "", prepare: "", execute: "", report: "" },
|
||||
position: { x: 0, y: 0 },
|
||||
} as AnyWorkNode;
|
||||
}
|
||||
|
||||
function startNode(): AnyWorkNode {
|
||||
return {
|
||||
id: "start",
|
||||
type: "start",
|
||||
data: { label: "Start" },
|
||||
position: { x: 0, y: 0 },
|
||||
} as AnyWorkNode;
|
||||
}
|
||||
|
||||
function endNode(): AnyWorkNode {
|
||||
return {
|
||||
id: "end",
|
||||
type: "end",
|
||||
data: { label: "End" },
|
||||
position: { x: 0, y: 0 },
|
||||
} as AnyWorkNode;
|
||||
}
|
||||
|
||||
function defaultEdge(source: string, target: string): AnyWorkEdge {
|
||||
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
|
||||
}
|
||||
|
||||
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
|
||||
return {
|
||||
id: `${source}-${target}-cond`,
|
||||
source,
|
||||
target,
|
||||
type: "conditional" as const,
|
||||
data: { condition },
|
||||
animated: true,
|
||||
} as AnyWorkEdge;
|
||||
}
|
||||
|
||||
// Helper: build a minimal valid graph with 2 role nodes for validateRoleNodes tests
|
||||
function baseNodes(...roles: AnyWorkNode[]): AnyWorkNode[] {
|
||||
return [startNode(), ...roles, endNode()];
|
||||
}
|
||||
|
||||
describe("validateRoleNodes (via validate)", () => {
|
||||
it("5.1 Role node with no incoming edge → error about missing input", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const nodes = baseNodes(n1, n2);
|
||||
// n1 has no incoming, n2 has incoming+outgoing
|
||||
const edges = [defaultEdge("start", "n2"), defaultEdge("n1", "end"), defaultEdge("n2", "end")];
|
||||
const result = validate(nodes, edges);
|
||||
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
|
||||
expect(nodeErrors.some((e) => e.message.includes("缺少输入连接"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.2 Role node with no outgoing edge → error about missing output", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const nodes = baseNodes(n1, n2);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
defaultEdge("start", "n2"),
|
||||
defaultEdge("n2", "end"),
|
||||
// n1 has no outgoing
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
|
||||
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.3 Empty condition on non-first conditional edge → error", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
|
||||
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", "x>0"),
|
||||
defaultEdge("n1", "n3"), // mix → error
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const nodes = baseNodes(n1, n2);
|
||||
const edges = [defaultEdge("start", "n1"), defaultEdge("n1", "n2"), defaultEdge("n2", "end")];
|
||||
const result = validate(nodes, edges);
|
||||
const roleErrors = result.errors.filter((e) => e.nodeId === "n1" || e.nodeId === "n2");
|
||||
expect(roleErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", ""), // else-branch
|
||||
conditionalEdge("n1", "n3", "x>0"), // if-branch
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
const n1Errors = result.errors.filter((e) => e.nodeId === "n1");
|
||||
expect(n1Errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './type';
|
||||
export * from './trans-in';
|
||||
export * from './trans-out';
|
||||
export * from './validate';
|
||||
export * from "./trans-in";
|
||||
export * from "./trans-out";
|
||||
export * from "./type";
|
||||
export * from "./validate";
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
|
||||
import type { WorkFlowStep } from './type';
|
||||
import { uuid } from '../utils';
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
||||
import { uuid } from "../utils";
|
||||
import type { WorkFlowStep } from "./type";
|
||||
|
||||
type Result = {
|
||||
nodes: AnyWorkNode[];
|
||||
edges: AnyWorkEdge[];
|
||||
};
|
||||
|
||||
const OUT_HANDLES = ['output-top', 'output', 'output-bottom'] as const;
|
||||
const IN_HANDLES = ['input-top', 'input', 'input-bottom'] as const;
|
||||
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
||||
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
||||
|
||||
function assignHandles(
|
||||
indices: number[],
|
||||
edges: AnyWorkEdge[],
|
||||
handles: readonly string[],
|
||||
key: 'sourceHandle' | 'targetHandle',
|
||||
key: "sourceHandle" | "targetHandle",
|
||||
): void {
|
||||
if (indices.length === 1) {
|
||||
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
|
||||
@@ -28,129 +28,147 @@ function assignHandles(
|
||||
}
|
||||
}
|
||||
|
||||
export function transIn(steps: WorkFlowStep[]): Result {
|
||||
const startNode: AnyWorkNode = { id: 'start', type: 'start', data: { label: 'Start' }, position: { x: 0, y: 0 } };
|
||||
const endNode: AnyWorkNode = { id: 'end', type: 'end', data: { label: 'End' }, position: { x: 250, y: 0 } };
|
||||
|
||||
if (steps.length === 0) {
|
||||
return { nodes: [startNode, endNode], edges: [] };
|
||||
}
|
||||
|
||||
const nodes: AnyWorkNode[] = [startNode, endNode];
|
||||
const edges: AnyWorkEdge[] = [];
|
||||
function buildNodeMap(
|
||||
steps: WorkFlowStep[],
|
||||
nodes: AnyWorkNode[],
|
||||
): { nameToId: Map<string, string>; idToOrder: Map<string, number> } {
|
||||
const nameToId = new Map<string, string>();
|
||||
const idToOrder = new Map<string, number>();
|
||||
nameToId.set('END', 'end');
|
||||
idToOrder.set('start', -1);
|
||||
idToOrder.set('end', steps.length);
|
||||
|
||||
nameToId.set("END", "end");
|
||||
idToOrder.set("start", -1);
|
||||
idToOrder.set("end", steps.length);
|
||||
for (let si = 0; si < steps.length; si++) {
|
||||
const step = steps[si];
|
||||
const nodeId = `n${uuid()}`;
|
||||
nameToId.set(step.role.name, nodeId);
|
||||
idToOrder.set(nodeId, si);
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'role',
|
||||
data: { ...step.role },
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
nodes.push({ id: nodeId, type: "role", data: { ...step.role }, position: { x: 0, y: 0 } });
|
||||
}
|
||||
return { nameToId, idToOrder };
|
||||
}
|
||||
|
||||
const firstStepId = nameToId.get(steps[0].role.name)!;
|
||||
edges.push({
|
||||
id: `e-start-${firstStepId}`,
|
||||
source: 'start',
|
||||
sourceHandle: 'output',
|
||||
target: firstStepId,
|
||||
targetHandle: 'input',
|
||||
animated: true,
|
||||
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
||||
if (step.transitions.length <= 1) return step.transitions;
|
||||
return [...step.transitions].sort((a, b) => {
|
||||
if (a.condition === null && b.condition !== null) return -1;
|
||||
if (a.condition !== null && b.condition === null) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name)!;
|
||||
const sourceOrder = idToOrder.get(sourceId)!;
|
||||
const hasMultipleTransitions = step.transitions.length > 1;
|
||||
function buildStepEdges(
|
||||
sourceId: string,
|
||||
step: WorkFlowStep,
|
||||
nameToId: Map<string, string>,
|
||||
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
|
||||
const hasMultiple = step.transitions.length > 1;
|
||||
const sorted = sortTransitions(step);
|
||||
const elseEdges: AnyWorkEdge[] = [];
|
||||
const ifEdges: AnyWorkEdge[] = [];
|
||||
|
||||
const sorted = hasMultipleTransitions
|
||||
? [...step.transitions].sort((a, b) => {
|
||||
if (a.condition === null && b.condition !== null) return -1;
|
||||
if (a.condition !== null && b.condition === null) return 1;
|
||||
return 0;
|
||||
})
|
||||
: step.transitions;
|
||||
|
||||
const elseEdges: AnyWorkEdge[] = [];
|
||||
const ifEdges: AnyWorkEdge[] = [];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const t = sorted[i];
|
||||
const targetId = nameToId.get(t.target);
|
||||
if (!targetId) continue;
|
||||
|
||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||
|
||||
if (hasMultipleTransitions || t.condition !== null) {
|
||||
const edge: ConditionalEdge = {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
type: 'conditional',
|
||||
data: { condition: t.condition ?? '' },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultipleTransitions && i === 0) {
|
||||
elseEdges.push(edge);
|
||||
} else {
|
||||
ifEdges.push(edge);
|
||||
}
|
||||
} else {
|
||||
elseEdges.push({
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom
|
||||
for (const e of elseEdges) {
|
||||
edges.push({ ...e, sourceHandle: 'output' });
|
||||
}
|
||||
if (ifEdges.length > 0) {
|
||||
const sortedIf = [...ifEdges].sort((a, b) => {
|
||||
const oa = idToOrder.get(a.target) ?? 0;
|
||||
const ob = idToOrder.get(b.target) ?? 0;
|
||||
return ob - oa;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const t = sorted[i];
|
||||
const targetId = nameToId.get(t.target);
|
||||
if (!targetId) continue;
|
||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||
if (hasMultiple || t.condition !== null) {
|
||||
const edge: ConditionalEdge = {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
type: "conditional",
|
||||
data: { condition: t.condition ?? "" },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultiple && i === 0) elseEdges.push(edge);
|
||||
else ifEdges.push(edge);
|
||||
} else {
|
||||
elseEdges.push({
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
animated: true,
|
||||
});
|
||||
const ifHandles = ['output-top', 'output-bottom'] as const;
|
||||
for (let i = 0; i < sortedIf.length; i++) {
|
||||
edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { elseEdges, ifEdges };
|
||||
}
|
||||
|
||||
// in: group by target, sort by source order asc (leftmost first), assign input > input-top > input-bottom
|
||||
function pushStepEdges(
|
||||
edges: AnyWorkEdge[],
|
||||
elseEdges: AnyWorkEdge[],
|
||||
ifEdges: AnyWorkEdge[],
|
||||
idToOrder: Map<string, number>,
|
||||
): void {
|
||||
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||
if (ifEdges.length > 0) {
|
||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
||||
const sorted = [...ifEdges].sort(
|
||||
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
||||
);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assignTargetHandles(edges: AnyWorkEdge[], idToOrder: Map<string, number>): void {
|
||||
const incomingByTarget = new Map<string, number[]>();
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const target = edges[i].target;
|
||||
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
|
||||
incomingByTarget.get(target)!.push(i);
|
||||
incomingByTarget.get(target)?.push(i);
|
||||
}
|
||||
for (const indices of incomingByTarget.values()) {
|
||||
indices.sort(
|
||||
(a, b) => (idToOrder.get(edges[a].source) ?? 0) - (idToOrder.get(edges[b].source) ?? 0),
|
||||
);
|
||||
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
|
||||
}
|
||||
}
|
||||
|
||||
export function transIn(steps: WorkFlowStep[]): Result {
|
||||
const startNode: AnyWorkNode = {
|
||||
id: "start",
|
||||
type: "start",
|
||||
data: { label: "Start" },
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
const endNode: AnyWorkNode = {
|
||||
id: "end",
|
||||
type: "end",
|
||||
data: { label: "End" },
|
||||
position: { x: 250, y: 0 },
|
||||
};
|
||||
|
||||
if (steps.length === 0) return { nodes: [startNode, endNode], edges: [] };
|
||||
|
||||
const nodes: AnyWorkNode[] = [startNode, endNode];
|
||||
const edges: AnyWorkEdge[] = [];
|
||||
|
||||
const { nameToId, idToOrder } = buildNodeMap(steps, nodes);
|
||||
|
||||
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
|
||||
edges.push({
|
||||
id: `e-start-${firstStepId}`,
|
||||
source: "start",
|
||||
sourceHandle: "output",
|
||||
target: firstStepId,
|
||||
targetHandle: "input",
|
||||
animated: true,
|
||||
});
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
||||
}
|
||||
|
||||
for (const indices of incomingByTarget.values()) {
|
||||
indices.sort((a, b) => {
|
||||
const oa = idToOrder.get(edges[a].source) ?? 0;
|
||||
const ob = idToOrder.get(edges[b].source) ?? 0;
|
||||
return oa - ob;
|
||||
});
|
||||
assignHandles(indices, edges, IN_HANDLES, 'targetHandle');
|
||||
}
|
||||
assignTargetHandles(edges, idToOrder);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, WorkNode, ConditionalEdge } from '../type';
|
||||
import type { WorkFlowStep, WorkFlowTransition } from './type';
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
|
||||
import type { WorkFlowStep, WorkFlowTransition } from "./type";
|
||||
|
||||
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
||||
const nodeMap = new Map<string, AnyWorkNode>();
|
||||
@@ -12,10 +12,10 @@ export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowSt
|
||||
if (!outgoingEdges.has(edge.source)) {
|
||||
outgoingEdges.set(edge.source, []);
|
||||
}
|
||||
outgoingEdges.get(edge.source)!.push(edge);
|
||||
outgoingEdges.get(edge.source)?.push(edge);
|
||||
}
|
||||
|
||||
const startOutEdges = outgoingEdges.get('start') ?? [];
|
||||
const startOutEdges = outgoingEdges.get("start") ?? [];
|
||||
if (startOutEdges.length === 0) return [];
|
||||
|
||||
const firstNodeId = startOutEdges[0].target;
|
||||
@@ -34,23 +34,26 @@ function traverse(
|
||||
visited: Set<string>,
|
||||
steps: WorkFlowStep[],
|
||||
): void {
|
||||
if (visited.has(nodeId) || nodeId === 'start' || nodeId === 'end') return;
|
||||
if (visited.has(nodeId) || nodeId === "start" || nodeId === "end") return;
|
||||
visited.add(nodeId);
|
||||
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node || node.type !== 'role') return;
|
||||
if (!node || node.type !== "role") return;
|
||||
|
||||
const roleNode = node as WorkNode<'role'>;
|
||||
const roleNode = node as WorkNode<"role">;
|
||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
||||
|
||||
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
|
||||
const targetNode = nodeMap.get(edge.target);
|
||||
const target = edge.target === 'end'
|
||||
? 'END'
|
||||
: (targetNode?.type === 'role' ? (targetNode as WorkNode<'role'>).data.name : edge.target);
|
||||
const target =
|
||||
edge.target === "end"
|
||||
? "END"
|
||||
: targetNode?.type === "role"
|
||||
? (targetNode as WorkNode<"role">).data.name
|
||||
: edge.target;
|
||||
|
||||
let condition: string | null = null;
|
||||
if (edge.type === 'conditional') {
|
||||
if (edge.type === "conditional") {
|
||||
const isElse = outEdges.length >= 2 && index === 0;
|
||||
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type {
|
||||
WorkFlowRole,
|
||||
WorkFlowTransition,
|
||||
WorkFlowStep,
|
||||
WorkFlowSteps,
|
||||
WorkFlowTransition,
|
||||
} from "../../../shared/types.ts";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
||||
|
||||
export type ValidationError = {
|
||||
nodeId: string | null;
|
||||
@@ -13,12 +13,12 @@ export type ValidationResult = {
|
||||
export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
const outgoing = buildEdgeMap(edges, 'source');
|
||||
const incoming = buildEdgeMap(edges, 'target');
|
||||
const outgoing = buildEdgeMap(edges, "source");
|
||||
const incoming = buildEdgeMap(edges, "target");
|
||||
|
||||
const startNodes = nodes.filter(n => n.type === 'start');
|
||||
const endNodes = nodes.filter(n => n.type === 'end');
|
||||
const roleNodes = nodes.filter(n => n.type === 'role');
|
||||
const startNodes = nodes.filter((n) => n.type === "start");
|
||||
const endNodes = nodes.filter((n) => n.type === "end");
|
||||
const roleNodes = nodes.filter((n) => n.type === "role");
|
||||
|
||||
validateStartNode(startNodes, outgoing, errors);
|
||||
validateEndNode(endNodes, incoming, outgoing, errors);
|
||||
@@ -29,17 +29,14 @@ export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): Validation
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
function buildEdgeMap(
|
||||
edges: AnyWorkEdge[],
|
||||
key: 'source' | 'target',
|
||||
): Map<string, AnyWorkEdge[]> {
|
||||
function buildEdgeMap(edges: AnyWorkEdge[], key: "source" | "target"): Map<string, AnyWorkEdge[]> {
|
||||
const map = new Map<string, AnyWorkEdge[]>();
|
||||
for (const edge of edges) {
|
||||
const id = edge[key];
|
||||
if (!map.has(id)) {
|
||||
map.set(id, []);
|
||||
}
|
||||
map.get(id)!.push(edge);
|
||||
map.get(id)?.push(edge);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -50,20 +47,20 @@ function validateStartNode(
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (startNodes.length === 0) {
|
||||
errors.push({ nodeId: null, message: '缺少 Start 节点' });
|
||||
errors.push({ nodeId: null, message: "缺少 Start 节点" });
|
||||
return;
|
||||
}
|
||||
if (startNodes.length > 1) {
|
||||
errors.push({ nodeId: null, message: 'Start 节点只能有一个' });
|
||||
errors.push({ nodeId: null, message: "Start 节点只能有一个" });
|
||||
return;
|
||||
}
|
||||
|
||||
const startId = startNodes[0].id;
|
||||
const outEdges = outgoing.get(startId) ?? [];
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: startId, message: 'Start 节点必须有一个输出连接' });
|
||||
errors.push({ nodeId: startId, message: "Start 节点必须有一个输出连接" });
|
||||
} else if (outEdges.length > 1) {
|
||||
errors.push({ nodeId: startId, message: 'Start 节点只能有一个输出连接' });
|
||||
errors.push({ nodeId: startId, message: "Start 节点只能有一个输出连接" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,23 +71,53 @@ function validateEndNode(
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (endNodes.length === 0) {
|
||||
errors.push({ nodeId: null, message: '缺少 End 节点' });
|
||||
errors.push({ nodeId: null, message: "缺少 End 节点" });
|
||||
return;
|
||||
}
|
||||
if (endNodes.length > 1) {
|
||||
errors.push({ nodeId: null, message: 'End 节点只能有一个' });
|
||||
errors.push({ nodeId: null, message: "End 节点只能有一个" });
|
||||
return;
|
||||
}
|
||||
|
||||
const endId = endNodes[0].id;
|
||||
const inEdges = incoming.get(endId) ?? [];
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: endId, message: 'End 节点必须有至少一个输入连接' });
|
||||
errors.push({ nodeId: endId, message: "End 节点必须有至少一个输入连接" });
|
||||
}
|
||||
|
||||
const outEdges = outgoing.get(endId) ?? [];
|
||||
if (outEdges.length > 0) {
|
||||
errors.push({ nodeId: endId, message: 'End 节点不能有输出连接' });
|
||||
errors.push({ nodeId: endId, message: "End 节点不能有输出连接" });
|
||||
}
|
||||
}
|
||||
|
||||
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
|
||||
return conditionalEdges.slice(1).some((edge) => {
|
||||
const cond = (edge as ConditionalEdge).data?.condition?.trim();
|
||||
return !cond;
|
||||
});
|
||||
}
|
||||
|
||||
function validateRoleNodeEdges(
|
||||
node: AnyWorkNode,
|
||||
outEdges: AnyWorkEdge[],
|
||||
inEdges: AnyWorkEdge[],
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
|
||||
}
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
|
||||
return;
|
||||
}
|
||||
if (outEdges.length <= 1) return;
|
||||
|
||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
||||
if (conditionalEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
||||
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
|
||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,40 +128,13 @@ function validateRoleNodes(
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
for (const node of roleNodes) {
|
||||
const inEdges = incoming.get(node.id) ?? [];
|
||||
const outEdges = outgoing.get(node.id) ?? [];
|
||||
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: '角色节点缺少输入连接' });
|
||||
}
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: '角色节点缺少输出连接' });
|
||||
}
|
||||
|
||||
if (outEdges.length > 1) {
|
||||
const conditionalEdges = outEdges.filter(e => e.type === 'conditional');
|
||||
if (conditionalEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: '多输出节点的所有出边必须附带条件' });
|
||||
} else {
|
||||
const ifEdges = conditionalEdges.slice(1);
|
||||
for (const edge of ifEdges) {
|
||||
const condEdge = edge as ConditionalEdge;
|
||||
if (!condEdge.data?.condition?.trim()) {
|
||||
errors.push({ nodeId: node.id, message: '条件边的条件表达式不能为空' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
validateRoleNodeEdges(node, outgoing.get(node.id) ?? [], incoming.get(node.id) ?? [], errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoleCount(
|
||||
roleNodes: AnyWorkNode[],
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
function validateRoleCount(roleNodes: AnyWorkNode[], errors: ValidationError[]): void {
|
||||
if (roleNodes.length < 2) {
|
||||
errors.push({ nodeId: null, message: '工作流至少需要 2 个角色节点' });
|
||||
errors.push({ nodeId: null, message: "工作流至少需要 2 个角色节点" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,21 +151,21 @@ function validateReachability(
|
||||
const backwardAdj = new Map<string, string[]>();
|
||||
for (const edge of edges) {
|
||||
if (!forwardAdj.has(edge.source)) forwardAdj.set(edge.source, []);
|
||||
forwardAdj.get(edge.source)!.push(edge.target);
|
||||
forwardAdj.get(edge.source)?.push(edge.target);
|
||||
if (!backwardAdj.has(edge.target)) backwardAdj.set(edge.target, []);
|
||||
backwardAdj.get(edge.target)!.push(edge.source);
|
||||
backwardAdj.get(edge.target)?.push(edge.source);
|
||||
}
|
||||
|
||||
const reachableFromStart = bfs(startNodes[0].id, forwardAdj);
|
||||
const reachableFromEnd = bfs(endNodes[0].id, backwardAdj);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'start' || node.type === 'end') continue;
|
||||
if (node.type === "start" || node.type === "end") continue;
|
||||
if (!reachableFromStart.has(node.id)) {
|
||||
errors.push({ nodeId: node.id, message: '节点不可从 Start 到达(孤立节点)' });
|
||||
errors.push({ nodeId: node.id, message: "节点不可从 Start 到达(孤立节点)" });
|
||||
}
|
||||
if (!reachableFromEnd.has(node.id)) {
|
||||
errors.push({ nodeId: node.id, message: '节点无法到达 End(死端节点)' });
|
||||
errors.push({ nodeId: node.id, message: "节点无法到达 End(死端节点)" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
|
||||
const queue = [startId];
|
||||
visited.add(startId);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const current = queue.shift() ?? "";
|
||||
for (const next of adj.get(current) ?? []) {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Node, Edge } from '@xyflow/react';
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
type AnyKeyBase = { [key: string]: unknown | undefined };
|
||||
|
||||
@@ -19,11 +19,11 @@ export type NodeMap = {
|
||||
|
||||
export type WorkNodeType = keyof NodeMap;
|
||||
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
|
||||
export type AnyWorkNode = WorkNode<'start'> | WorkNode<'end'> | WorkNode<'role'>;
|
||||
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
||||
|
||||
export type ConditionalEdgeData = AnyKeyBase & {
|
||||
condition: string;
|
||||
};
|
||||
|
||||
export type ConditionalEdge = Edge<ConditionalEdgeData, 'conditional'>;
|
||||
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
|
||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
interface Maper<T> { [key: string]: T }
|
||||
type Maper<T> = {
|
||||
[key: string]: T;
|
||||
};
|
||||
type Listen<T> = (data: T) => void;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: generic event map requires any
|
||||
export class Eventer<M extends Maper<any>> {
|
||||
// biome-ignore lint/complexity/noBannedTypes: Set<Function> needed for heterogeneous listener types
|
||||
private lisenters = {} as { [K in keyof M]: Set<Function> };
|
||||
|
||||
public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) {
|
||||
let set = this.lisenters[key];
|
||||
if (set == undefined) {
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.lisenters[key] = set;
|
||||
}
|
||||
@@ -26,6 +30,8 @@ export class Eventer<M extends Maper<any>> {
|
||||
const set = this.lisenters[key];
|
||||
if (set === undefined) return;
|
||||
// Todo: maybe implement stoping bubble
|
||||
set.forEach(call => call(data));
|
||||
for (const call of set) {
|
||||
call(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
export function uuid() {
|
||||
const now = Date.now();
|
||||
const randon = 1 + Math.random();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
function judge(container: HTMLElement, target: HTMLElement): boolean {
|
||||
if (container === target) {
|
||||
@@ -8,14 +7,11 @@ function judge(container: HTMLElement, target: HTMLElement): boolean {
|
||||
if (target === document.body) {
|
||||
return false;
|
||||
}
|
||||
let parent = target.parentElement;
|
||||
const parent = target.parentElement;
|
||||
return parent ? judge(container, parent) : false;
|
||||
}
|
||||
|
||||
export function useClickOutRef<T extends HTMLElement>(
|
||||
callback: () => void,
|
||||
delay = 0,
|
||||
) {
|
||||
export function useClickOutRef<T extends HTMLElement>(callback: () => void, delay = 0) {
|
||||
const ref = useRef<T>(null);
|
||||
const flag = useRef<boolean>(delay === 0);
|
||||
|
||||
@@ -37,8 +33,8 @@ export function useClickOutRef<T extends HTMLElement>(
|
||||
callback();
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handle);
|
||||
return () => document.removeEventListener('click', handle);
|
||||
document.addEventListener("click", handle);
|
||||
return () => document.removeEventListener("click", handle);
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--font-sans: "Geist Variable", sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -147,4 +147,4 @@ body {
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from "react";
|
||||
import { useParams, useNavigate, useLocation } from "react-router";
|
||||
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
|
||||
import { ArrowLeft, Eye, Pencil } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Pencil, Eye } from "lucide-react";
|
||||
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
|
||||
|
||||
export function DetailPage(): ReactNode {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
@@ -44,18 +44,18 @@ export function DetailPage(): ReactNode {
|
||||
if (!cancelled) navigate("/");
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [name, navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">加载中...</div>
|
||||
);
|
||||
}
|
||||
|
||||
const basePath = `/workflow/${encodeURIComponent(name!)}`;
|
||||
const basePath = `/workflow/${encodeURIComponent(name ?? "")}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -66,29 +66,19 @@ export function DetailPage(): ReactNode {
|
||||
<h1 className="text-base font-medium">{name}</h1>
|
||||
<div className="flex-1" />
|
||||
{editing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(basePath)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(basePath)}>
|
||||
<Eye className="size-3.5" data-icon="inline-start" />
|
||||
预览
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`${basePath}/edit`)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`${basePath}/edit`)}>
|
||||
<Pencil className="size-3.5" data-icon="inline-start" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{saving && <span className="text-xs text-muted-foreground">保存中...</span>}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{model && <FlowEditor model={model} readonly={!editing} />}
|
||||
</div>
|
||||
<div className="flex-1">{model && <FlowEditor model={model} readonly={!editing} />}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
|
||||
|
||||
const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user