Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9fcb15384 | |||
| 5e868a2977 | |||
| 76fab22827 | |||
| 669875fb46 | |||
| 6d94be34a9 | |||
| d95fe45a3d | |||
| b9252b5ce2 | |||
| 4d47effd39 | |||
| 7b93ce8f3e | |||
| 67870392ab | |||
| 9316b843f6 | |||
| 6b9ff9781d | |||
| 487c48effa | |||
| 4eca2d533c | |||
| f0f840e6e0 | |||
| 7ff90cef4f | |||
| e62d51d845 | |||
| a803fcb4fc | |||
| d00c93fc19 | |||
| 99a2890be2 | |||
| 3b7d0564bb | |||
| 45dacf540b | |||
| 2eb5ee0666 | |||
| e67932c83c | |||
| 04a12231c3 | |||
| e5ae9a134c | |||
| bdafaf3aa1 | |||
| 02f7f0b708 | |||
| 8ea554bb5e | |||
| 8a425521da | |||
| f174f2fd0a | |||
| 355594d074 | |||
| fd7609fe90 | |||
| dacecfbbb7 | |||
| 3238eaeddf | |||
| 995f273fa5 | |||
| 866154ad73 | |||
| 8efc5050cb | |||
| 3fb60ee649 | |||
| e181f67a2d | |||
| a3114bf840 | |||
| e59ae9aca1 | |||
| c050a38f38 | |||
| c60c310074 | |||
| fe035c065d | |||
| 192ad656a4 | |||
| c0c8d6499e | |||
| 505f85e3c4 | |||
| fc7d482b4f | |||
| f9979c3c89 | |||
| 46def2945a | |||
| 4e89508246 | |||
| 77d799d458 | |||
| 6c14259184 | |||
| 7b9cb6a9c8 |
@@ -1,40 +0,0 @@
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Engine — Environment Variables
|
||||
# ──────────────────────────────────────────────
|
||||
# Copy this file to .env and fill in the values.
|
||||
|
||||
# ── Cursor Agent ──
|
||||
|
||||
# CLI command to invoke the Cursor agent (required for develop workflow)
|
||||
WORKFLOW_CURSOR_COMMAND=
|
||||
|
||||
# Model override for Cursor agent
|
||||
WORKFLOW_CURSOR_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Cursor agent operations
|
||||
WORKFLOW_CURSOR_TIMEOUT=
|
||||
|
||||
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
|
||||
|
||||
# CLI command to invoke the Hermes agent (absolute path required)
|
||||
WORKFLOW_HERMES_COMMAND=
|
||||
|
||||
# Model override for Hermes agent
|
||||
WORKFLOW_HERMES_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Hermes agent operations
|
||||
WORKFLOW_HERMES_TIMEOUT=
|
||||
|
||||
# ── Storage ──
|
||||
|
||||
# Override the workflow storage root directory
|
||||
# Default: ~/.uncaged/workflow
|
||||
WORKFLOW_STORAGE_ROOT=
|
||||
|
||||
# Gateway secret for the serve command
|
||||
WORKFLOW_DASHBOARD_SECRET=
|
||||
|
||||
# ── Display ──
|
||||
|
||||
# Set to any value to disable colored output
|
||||
# NO_COLOR=1
|
||||
@@ -11,3 +11,5 @@ solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
*.py
|
||||
.claude
|
||||
tmp
|
||||
@@ -0,0 +1,83 @@
|
||||
# Test Spec: uwf setup model connectivity validation (#335)
|
||||
|
||||
## Context
|
||||
|
||||
File: `packages/cli-workflow/src/commands/setup.ts`
|
||||
Test file: `packages/cli-workflow/src/__tests__/setup-validate.test.ts`
|
||||
|
||||
After `cmdSetup` writes config, it should send a test chat completion request to verify the configured model is reachable. If validation fails, warn the user (don't abort — config is already saved).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Add a `validateModel(baseUrl, apiKey, model)` function that sends a minimal chat completion request (`POST /chat/completions` with `messages: [{role:"user",content:"hi"}]`, `max_tokens: 1`)
|
||||
- Returns `Result<void, string>` — ok if 2xx response, error with reason string otherwise
|
||||
- Use `AbortSignal.timeout(15_000)` for the request
|
||||
- Both `cmdSetup` and `cmdSetupInteractive` should call it after saving config
|
||||
- `cmdSetup` returns validation result in its return object: `{ ...existing, validation: { ok: true } | { ok: false, error: string } }`
|
||||
- `cmdSetupInteractive` prints a warning to console if validation fails, success message if it passes
|
||||
- Use the project logger (`createLogger`) — no raw `console.log` except in interactive CLI output (per CLAUDE.md)
|
||||
|
||||
## Test Cases (vitest)
|
||||
|
||||
### 1. `validateModel` — success path
|
||||
- Mock `fetch` to return `{ status: 200, ok: true, json: () => ({}) }`
|
||||
- Call `validateModel(baseUrl, apiKey, model)`
|
||||
- Assert returns `{ ok: true, value: undefined }`
|
||||
- Assert fetch was called with correct URL (`${baseUrl}/chat/completions`), correct headers (`Authorization: Bearer ${apiKey}`), correct body (model, messages, max_tokens: 1)
|
||||
|
||||
### 2. `validateModel` — HTTP error (401 unauthorized)
|
||||
- Mock `fetch` to return `{ status: 401, ok: false, statusText: "Unauthorized" }`
|
||||
- Call `validateModel(baseUrl, apiKey, model)`
|
||||
- Assert returns `{ ok: false, error: <string containing "401"> }`
|
||||
|
||||
### 3. `validateModel` — HTTP error (404 model not found)
|
||||
- Mock `fetch` to return `{ status: 404, ok: false, statusText: "Not Found" }`
|
||||
- Assert returns `{ ok: false, error: <string containing "404"> }`
|
||||
|
||||
### 4. `validateModel` — network timeout
|
||||
- Mock `fetch` to throw `DOMException` with name `AbortError`
|
||||
- Assert returns `{ ok: false, error: <string containing "timeout" or "unreachable"> }`
|
||||
|
||||
### 5. `validateModel` — network error (DNS failure, connection refused)
|
||||
- Mock `fetch` to throw `TypeError("fetch failed")`
|
||||
- Assert returns `{ ok: false, error: <string mentioning connectivity> }`
|
||||
|
||||
### 6. `cmdSetup` — includes validation result on success
|
||||
- Mock global `fetch` for `/chat/completions` to succeed
|
||||
- Call `cmdSetup({ provider, baseUrl, apiKey, model, storageRoot })`
|
||||
- Assert returned object has `validation: { ok: true, value: undefined }`
|
||||
- Assert config files are still written (existing behavior preserved)
|
||||
|
||||
### 7. `cmdSetup` — includes validation result on failure (config still saved)
|
||||
- Mock global `fetch` for `/chat/completions` to return 401
|
||||
- Call `cmdSetup({ ... })`
|
||||
- Assert returned object has `validation: { ok: false, error: ... }`
|
||||
- Assert `config.yaml` and `.env` are still written (validation failure doesn't prevent saving)
|
||||
|
||||
### 8. `cmdSetupInteractive` — prints success message on validation pass
|
||||
- Mock `fetch` for both `/models` and `/chat/completions` to succeed
|
||||
- Mock stdin to provide valid selections
|
||||
- Capture console output
|
||||
- Assert output contains a success message like "Model verified" or "✓"
|
||||
|
||||
### 9. `cmdSetupInteractive` — prints warning on validation failure
|
||||
- Mock `fetch`: `/models` succeeds, `/chat/completions` returns 401
|
||||
- Mock stdin for valid selections
|
||||
- Capture console output
|
||||
- Assert output contains a warning about model not being reachable and suggests trying a different model
|
||||
|
||||
### 10. `validateModel` — request body correctness
|
||||
- Mock `fetch` to capture the request body
|
||||
- Call `validateModel(baseUrl, apiKey, "test-model")`
|
||||
- Assert body is `{ model: "test-model", messages: [{role: "user", content: "hi"}], max_tokens: 1 }`
|
||||
|
||||
## Export Requirements
|
||||
|
||||
- `validateModel` must be exported (for direct unit testing)
|
||||
- Signature: `async function validateModel(baseUrl: string, apiKey: string, model: string): Promise<Result<void, string>>`
|
||||
- `Result` type: `{ ok: true; value: T } | { ok: false; error: E }` (project convention)
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
- **New**: `packages/cli-workflow/src/__tests__/setup-validate.test.ts` — all test cases above
|
||||
- **Modify**: `packages/cli-workflow/src/commands/setup.ts` — add `validateModel`, integrate into `cmdSetup` and `cmdSetupInteractive`
|
||||
@@ -0,0 +1,167 @@
|
||||
name: "solve-issue"
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
On first run (no previous steps):
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Read CLAUDE.md (or equivalent project conventions file) to understand coding standards
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output status=insufficient_info and terminate
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
2. Revise the test spec accordingly
|
||||
|
||||
After producing the test spec:
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in frontmatter.plan (required when status=ready)
|
||||
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ready, insufficient_info]
|
||||
plan:
|
||||
type: string
|
||||
required: [status]
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
1. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's meta.plan)
|
||||
2. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
||||
3. Write tests first based on the spec
|
||||
4. Implement the code to make tests pass
|
||||
5. Ensure `bun run build` passes with no errors
|
||||
6. 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
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [done, failed]
|
||||
required: [status]
|
||||
reviewer:
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
Hard checks (must all pass):
|
||||
1. `bun run build` — no build errors
|
||||
2. `bunx biome check` — no lint violations
|
||||
3. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against CLAUDE.md conventions):
|
||||
- Functional-first: `function` + `type`, not `class` + `interface`
|
||||
- No optional properties (`?:`) — use `T | null`
|
||||
- Naming conventions (kebab-case files, PascalCase types, camelCase functions)
|
||||
- Module boundary discipline (folder exports via index.ts)
|
||||
- No `console.log` (use structured logger)
|
||||
- No dynamic imports in production code
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
approved:
|
||||
type: boolean
|
||||
required: [approved]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
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 meta.plan)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [passed, fix_code, fix_spec]
|
||||
required: [status]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
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"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- 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
|
||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
required: [success]
|
||||
conditions:
|
||||
insufficientInfo:
|
||||
description: "Planner determined there's not enough info to proceed"
|
||||
expression: "$last('planner').status = 'insufficient_info'"
|
||||
devFailed:
|
||||
description: "Developer failed to implement"
|
||||
expression: "$last('developer').status = 'failed'"
|
||||
rejected:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
fixCode:
|
||||
description: "Tester found code issues"
|
||||
expression: "$last('tester').status = 'fix_code'"
|
||||
fixSpec:
|
||||
description: "Tester found spec issues"
|
||||
expression: "$last('tester').status = 'fix_spec'"
|
||||
hookFailed:
|
||||
description: "Push hook failed"
|
||||
expression: "$last('committer').success = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
planner:
|
||||
- role: "$END"
|
||||
condition: "insufficientInfo"
|
||||
- role: "developer"
|
||||
developer:
|
||||
- role: "$END"
|
||||
condition: "devFailed"
|
||||
- role: "reviewer"
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "rejected"
|
||||
- role: "tester"
|
||||
tester:
|
||||
- role: "developer"
|
||||
condition: "fixCode"
|
||||
- role: "planner"
|
||||
condition: "fixSpec"
|
||||
- role: "committer"
|
||||
committer:
|
||||
- role: "developer"
|
||||
condition: "hookFailed"
|
||||
- role: "$END"
|
||||
@@ -2,46 +2,41 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
This monorepo implements a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier. Shared types live in `@uncaged/workflow-protocol`; bundle authors typically depend on `@uncaged/workflow-runtime`.
|
||||
This monorepo implements a stateless workflow engine driven by a single-step CLI (`uwf`). Workflows are **YAML definitions** stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
||||
|
||||
### Key Terms
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. State lives in CAS (linked nodes); active threads indexed in `threads.json`; completed rows in `history/*.jsonl`. Debug logs use `.info.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, conditions, and a routing graph. Stored as a CAS node, identified by its XXH64 hash. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
|
||||
| **Moderator** | JSONata-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
||||
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
|
||||
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
|
||||
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
workflow/
|
||||
packages/
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types + Result
|
||||
workflow-runtime/ # @uncaged/workflow-runtime — createWorkflow, type re-exports
|
||||
workflow-util/ # @uncaged/workflow-util — Base32, ULID, logger, storage paths, refs helpers
|
||||
workflow-reactor/ # @uncaged/workflow-reactor — LLM fn + thread reactor (tool calls)
|
||||
workflow-cas/ # @uncaged/workflow-cas — CAS store, hash, Merkle
|
||||
workflow-register/ # @uncaged/workflow-register — bundle validation, registry YAML, model resolution
|
||||
workflow-execute/ # @uncaged/workflow-execute — engine, extract, fork, GC, workflowAsAgent
|
||||
cli-workflow/ # @uncaged/cli-workflow — uncaged-workflow CLI
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||
workflow-dashboard/ # @uncaged/workflow-dashboard — React dashboard (private app)
|
||||
docs/ # RFCs, conventions
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
||||
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
||||
workflow-moderator/ # @uncaged/workflow-moderator — JSONata graph evaluator
|
||||
workflow-agent-kit/ # @uncaged/workflow-agent-kit — createAgent factory, context builder, extract pipeline
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
|
||||
legacy-packages/ # Archived packages (preserved for reference, not active)
|
||||
examples/ # Workflow YAML examples (solve-issue.yaml)
|
||||
docs/ # Architecture docs
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
```
|
||||
|
||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||
- Dependency layers: `workflow-protocol` → (`workflow-util`, `workflow-moderator`) → `workflow-agent-kit` → `workflow-agent-hermes` / `cli-workflow`
|
||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
@@ -109,8 +104,6 @@ type WorkflowEntry = {
|
||||
- Always named exports, never default exports
|
||||
- One module = one responsibility, filename = purpose
|
||||
|
||||
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
|
||||
|
||||
### Folder Module Discipline
|
||||
|
||||
Every folder under `src/` is a **module boundary**. Four rules:
|
||||
@@ -136,10 +129,10 @@ export { createCasStore } from "../cas/cas.js";
|
||||
|
||||
// ❌ Bad — types defined in index.ts
|
||||
// in cas/index.ts:
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
```
|
||||
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`) are not inside a folder module and follow normal rules.
|
||||
|
||||
## Naming
|
||||
|
||||
@@ -160,7 +153,7 @@ Workflow names use **verb-first** kebab-case:
|
||||
### ID Encoding
|
||||
|
||||
All IDs use **Crockford Base32**:
|
||||
- Bundle hash: XXH64 → 13-char Crockford Base32
|
||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
||||
|
||||
## Error Handling
|
||||
@@ -189,7 +182,7 @@ import { createLogger } from "@uncaged/workflow-util";
|
||||
const log = createLogger();
|
||||
|
||||
// Each call site has a fixed 8-char Crockford Base32 tag
|
||||
log("4KNMR2PX", "Loading workflow bundle...");
|
||||
log("4KNMR2PX", "Loading workflow...");
|
||||
log("7BQST3VW", `Role ${role} started`);
|
||||
```
|
||||
|
||||
@@ -204,7 +197,7 @@ log("7BQST3VW", `Role ${role} started`);
|
||||
|
||||
### Why fixed tags?
|
||||
|
||||
- `grep "4KNMR2PX"` in `.info.jsonl` → instant code location
|
||||
- `grep "4KNMR2PX"` in logs → instant code location
|
||||
- No need for file/line info in the log — tag is the locator
|
||||
- Survives refactoring (tag stays the same when code moves)
|
||||
|
||||
@@ -221,74 +214,76 @@ console.log(result);
|
||||
|
||||
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
||||
|
||||
**Exception**: The bundle loader and `extractBundleExports` dynamically import user workflow files at runtime.
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **bun** | Package manager + runtime + test runner |
|
||||
| **bun** | Package manager + runtime |
|
||||
| **TypeScript** | Type checking (strict mode) |
|
||||
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
||||
| **vitest** | Test runner (`cli-workflow` uses vitest; other packages use `bun test`) |
|
||||
|
||||
### Commands
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
bun run check # tsc --build + biome check
|
||||
bun run format # biome format --write
|
||||
bun test # run tests
|
||||
# ── Setup ──
|
||||
bun install # install all workspace dependencies
|
||||
|
||||
# ── Daily development ──
|
||||
bun run build # tsc --build (all packages, dependency order)
|
||||
bun run check # tsc --build + biome check + lint-log-tags
|
||||
bun run format # biome format --write
|
||||
bun test # run tests across all packages
|
||||
|
||||
# ── Before committing ──
|
||||
bun run check # must pass — typecheck + lint + log tag validation
|
||||
bun test # must pass — all package tests
|
||||
```
|
||||
|
||||
### Version Management & Publishing
|
||||
### Publishing
|
||||
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** with **fixed mode** (all packages share the same version number).
|
||||
|
||||
```bash
|
||||
# 1. After making changes, add a changeset describing the change
|
||||
# 1. Add a changeset describing the change
|
||||
bun changeset
|
||||
|
||||
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||
# 2. Bump all package versions + generate CHANGELOGs
|
||||
bun version
|
||||
|
||||
# 3. Build, test, and publish to npmjs
|
||||
# 3. Build, test, and publish (runs scripts/publish-all.mjs)
|
||||
bun release
|
||||
|
||||
# Or publish manually with a tag:
|
||||
node scripts/publish-all.mjs --tag alpha
|
||||
node scripts/publish-all.mjs --dry-run # preview without publishing
|
||||
```
|
||||
|
||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||
- Publish order defined in `scripts/publish-all.mjs` (dependency order)
|
||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||
- Each package has auto-generated `CHANGELOG.md`
|
||||
|
||||
### Consuming @uncaged/* Packages
|
||||
|
||||
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||
|
||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||
### End-to-end: Author → Register → Run
|
||||
|
||||
```
|
||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||
│ bun release — build + test + changeset publish
|
||||
examples/solve-issue.yaml — write a workflow YAML definition
|
||||
│ uwf workflow put
|
||||
▼
|
||||
npmjs.org — @uncaged/* scoped packages (public)
|
||||
│ bun install
|
||||
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
|
||||
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
|
||||
│ uwf thread start <name> -p "..."
|
||||
▼
|
||||
my-workflows/ (workspace) — normal package.json
|
||||
│ bun run build:develop — bun build → single .esm.js
|
||||
~/.uncaged/workflow/threads.yaml — new thread head pointer
|
||||
│ uwf thread step <thread-id>
|
||||
▼
|
||||
uncaged-workflow workflow add — register bundle locally
|
||||
uncaged-workflow run — execute workflow
|
||||
moderator → agent → extract — one step per invocation, repeat until $END
|
||||
```
|
||||
|
||||
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||
1. **Author** — write a workflow YAML file with roles, conditions, and graph
|
||||
2. **Register** — `uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
|
||||
3. **Run** — `uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -296,5 +291,5 @@ uncaged-workflow run — execute workflow
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: workflow | cli | rfc-001 | ...
|
||||
scope: workflow | cli | moderator | agent-kit | hermes | util | protocol | ...
|
||||
```
|
||||
|
||||
@@ -1,71 +1,93 @@
|
||||
# @uncaged/workflow
|
||||
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
|
||||
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.
|
||||
|
||||
## Core Concepts
|
||||
## Package Map
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||
| 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 |
|
||||
|
||||
## Monorepo Packages
|
||||
|
||||
```
|
||||
packages/
|
||||
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
|
||||
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
|
||||
```
|
||||
|
||||
Managed with **bun workspace** using the `workspace:*` protocol.
|
||||
External: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (CAS store + JSON Schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
# 1. Configure provider and model
|
||||
uwf setup
|
||||
|
||||
# Build all packages
|
||||
bun run build
|
||||
# 2. Register a workflow from YAML
|
||||
uwf workflow put examples/solve-issue.yaml
|
||||
|
||||
# Register a workflow bundle
|
||||
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||
# 3. Start a thread
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
|
||||
# Run a workflow
|
||||
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||
# 4. Execute steps (one at a time, until done)
|
||||
uwf thread step <thread-id>
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
uncaged-workflow # Print full command usage (exits with status 1)
|
||||
uncaged-workflow workflow list # List registered workflows
|
||||
uncaged-workflow run <name> # Start a workflow thread
|
||||
uncaged-workflow thread list # List all threads
|
||||
uncaged-workflow thread show <id> # Inspect a thread
|
||||
uncaged-workflow skill # Agent-consumable reference docs
|
||||
```
|
||||
### Thread
|
||||
|
||||
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill 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 |
|
||||
|
||||
### 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>` | 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`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run check # Biome lint + format check
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run tests
|
||||
bun install --no-cache # Install dependencies
|
||||
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, bundle contract, storage layout, and design decisions.
|
||||
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.
|
||||
|
||||
+13
-1
@@ -5,6 +5,8 @@
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/node_modules",
|
||||
"!**/legacy-packages",
|
||||
"!scripts",
|
||||
"!packages/workflow/workflow",
|
||||
"!xiaoju/scripts/bundle.ts"
|
||||
]
|
||||
@@ -36,7 +38,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.d.ts"],
|
||||
"includes": ["**/*.d.ts", "**/vitest.config.*"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
@@ -44,6 +46,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/cli.ts", "**/setup.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noConsole": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linter": {
|
||||
|
||||
+50
-33
@@ -1,4 +1,4 @@
|
||||
# uwf — Architecture
|
||||
# Workflow Engine — Architecture
|
||||
|
||||
**Last updated:** 2026-05-19
|
||||
|
||||
@@ -14,12 +14,12 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|
||||
|
||||
| Layer | Package | One-line role |
|
||||
|-------|---------|---------------|
|
||||
| Contract | `@uncaged/uwf-protocol` → `uwf-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
|
||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
|
||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
|
||||
| Moderator | `@uncaged/uwf-moderator` → `uwf-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
|
||||
| Agent framework | `@uncaged/uwf-agent-kit` → `uwf-agent-kit` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
||||
| Agent: Hermes | `@uncaged/uwf-agent-hermes` → `uwf-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
|
||||
| CLI | `@uncaged/cli-uwf` → `cli-uwf` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
|
||||
| Moderator | `@uncaged/workflow-moderator` → `workflow-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
|
||||
| Agent framework | `@uncaged/workflow-agent-kit` → `workflow-agent-kit` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
||||
| Agent: Hermes | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
|
||||
|
||||
### External dependencies
|
||||
|
||||
@@ -27,8 +27,8 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|
||||
|---------|------|
|
||||
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
||||
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
|
||||
| `jsonata` | JSONata expression evaluator (used by `uwf-moderator`). |
|
||||
| `commander` | CLI argument parsing (used by `cli-uwf`). |
|
||||
| `jsonata` | JSONata expression evaluator (used by `workflow-moderator`). |
|
||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
||||
| `dotenv` | Loads `.env` files for API keys. |
|
||||
| `yaml` | YAML parse/stringify. |
|
||||
|
||||
@@ -41,20 +41,20 @@ flowchart BT
|
||||
jcasfs["@uncaged/json-cas-fs"]
|
||||
end
|
||||
subgraph L0["Layer 0 — contract"]
|
||||
protocol["@uncaged/uwf-protocol"]
|
||||
protocol["@uncaged/workflow-protocol"]
|
||||
end
|
||||
subgraph L1["Layer 1 — shared"]
|
||||
util["@uncaged/workflow-util"]
|
||||
moderator["@uncaged/uwf-moderator"]
|
||||
moderator["@uncaged/workflow-moderator"]
|
||||
end
|
||||
subgraph L2["Layer 2 — agent framework"]
|
||||
kit["@uncaged/uwf-agent-kit"]
|
||||
kit["@uncaged/workflow-agent-kit"]
|
||||
end
|
||||
subgraph L3["Layer 3 — agent implementations"]
|
||||
hermes["@uncaged/uwf-agent-hermes"]
|
||||
hermes["@uncaged/workflow-agent-hermes"]
|
||||
end
|
||||
subgraph L4["Layer 4 — CLI"]
|
||||
cli["@uncaged/cli-uwf"]
|
||||
cli["@uncaged/cli-workflow"]
|
||||
end
|
||||
protocol --> jcasfs
|
||||
util --> protocol
|
||||
@@ -85,8 +85,13 @@ description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
systemPrompt: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
||||
outputSchema:
|
||||
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
||||
output: "Output the plan summary and list of concrete steps."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
plan: { type: string }
|
||||
@@ -94,8 +99,13 @@ roles:
|
||||
required: [plan, steps]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
systemPrompt: "You are a developer agent. Implement the plan."
|
||||
outputSchema:
|
||||
goal: "You are a developer agent. Implement the plan."
|
||||
capabilities:
|
||||
- file-edit
|
||||
- shell
|
||||
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
||||
output: "List all files changed and provide a summary of the implementation."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
filesChanged: { type: array, items: { type: string } }
|
||||
@@ -103,8 +113,12 @@ roles:
|
||||
required: [filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
systemPrompt: "You are a code reviewer. Review the implementation."
|
||||
outputSchema:
|
||||
goal: "You are a code reviewer. Review the implementation."
|
||||
capabilities:
|
||||
- code-review
|
||||
procedure: "Review the implementation against the plan."
|
||||
output: "Approve or reject with detailed comments."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
approved: { type: boolean }
|
||||
@@ -133,7 +147,7 @@ graph:
|
||||
|
||||
Key properties:
|
||||
|
||||
- **`roles`** — inline role definitions; each `outputSchema` is a JSON Schema (stored as its own CAS node on registration)
|
||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
||||
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
|
||||
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
|
||||
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
||||
@@ -141,7 +155,7 @@ Key properties:
|
||||
|
||||
## Three-phase engine loop
|
||||
|
||||
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli-uwf/src/commands/thread.ts` (`cmdThreadStep`).
|
||||
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli-workflow/src/commands/thread.ts` (`cmdThreadStep`).
|
||||
|
||||
```
|
||||
┌─→ Phase 1: MODERATOR
|
||||
@@ -156,7 +170,7 @@ Each `uwf thread step` runs exactly one cycle: moderator → agent → extract.
|
||||
│ Output: raw string (frontmatter markdown)
|
||||
│
|
||||
│ Phase 3: EXTRACT
|
||||
│ Input: raw agent output + role's outputSchema
|
||||
│ Input: raw agent output + role's meta schema
|
||||
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
|
||||
│ Output: CasRef to structured output node
|
||||
│
|
||||
@@ -167,7 +181,7 @@ Each `uwf thread step` runs exactly one cycle: moderator → agent → extract.
|
||||
|
||||
### Context types
|
||||
|
||||
Defined in `packages/uwf-protocol/src/types.ts`:
|
||||
Defined in `packages/workflow-protocol/src/types.ts`:
|
||||
|
||||
```typescript
|
||||
type StepContext = {
|
||||
@@ -209,11 +223,11 @@ Each agent is an external command invoked by `uwf thread step`:
|
||||
Contract:
|
||||
1. `uwf thread step` determines the next role via the moderator
|
||||
2. Agent CLI is spawned with `(thread-id, role)` as positional args
|
||||
3. `uwf-agent-kit` (`createAgent`) handles the boilerplate:
|
||||
3. `workflow-agent-kit` (`createAgent`) handles the boilerplate:
|
||||
- Parses argv
|
||||
- Loads `.env` from storage root
|
||||
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
||||
- Resolves the role's `outputSchema` and builds `outputFormatInstruction`
|
||||
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
|
||||
- Calls the agent's `run` function
|
||||
- Runs two-layer extract on the raw output
|
||||
- Writes `StepNode` to CAS (output + detail + prev link)
|
||||
@@ -242,18 +256,18 @@ scope: role
|
||||
Fixed the login redirect by updating the auth middleware...
|
||||
```
|
||||
|
||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `uwf-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's JSON Schema.
|
||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
|
||||
|
||||
## Two-layer extract
|
||||
|
||||
Structured output extraction uses a two-layer strategy (`uwf-agent-kit`):
|
||||
Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
|
||||
|
||||
### Layer 1: frontmatter fast path (`frontmatter.ts`)
|
||||
|
||||
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
|
||||
2. Validate required fields (`validateFrontmatter`)
|
||||
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
|
||||
4. `store.put()` the candidate against the role's `outputSchema`
|
||||
4. `store.put()` the candidate against the role's `meta` schema
|
||||
5. Validate with `json-cas` schema validation
|
||||
6. If valid → return `outputHash` (zero LLM cost)
|
||||
|
||||
@@ -270,9 +284,9 @@ If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy sch
|
||||
|
||||
## Prompt injection
|
||||
|
||||
`uwf-agent-kit` prepends two pieces of context to the agent's system prompt:
|
||||
`workflow-agent-kit` prepends two pieces of context to the agent's system prompt:
|
||||
|
||||
1. **Deliverable format instruction** — generated from the role's `outputSchema`, tells the agent exactly what frontmatter fields to produce and the expected format
|
||||
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
|
||||
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
|
||||
|
||||
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
|
||||
@@ -289,8 +303,11 @@ payload:
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
systemPrompt: "You are a planning agent..."
|
||||
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
|
||||
goal: "You are a planning agent..."
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected"
|
||||
@@ -318,7 +335,7 @@ payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against outputSchema)
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against meta schema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
|
||||
agent: "uwf-hermes" # agent command used (plain string)
|
||||
```
|
||||
|
||||
+33
-21
@@ -112,8 +112,8 @@ uwf-hermes <thread-id> <role>
|
||||
|
||||
**约定:**
|
||||
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
||||
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
|
||||
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
|
||||
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
|
||||
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
|
||||
- agent 执行实际逻辑,agent-kit 负责 extract
|
||||
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
||||
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
||||
@@ -143,7 +143,7 @@ uwf-hermes <thread-id> <role>
|
||||
|
||||
#### `Workflow`
|
||||
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
@@ -153,16 +153,25 @@ payload:
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
systemPrompt: "You are a planning agent..."
|
||||
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
goal: "You are a planning agent..."
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
systemPrompt: "You are a developer agent..."
|
||||
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
goal: "You are a developer agent..."
|
||||
capabilities: [file-edit, shell]
|
||||
procedure: "Implement the plan."
|
||||
output: "List all files changed."
|
||||
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
systemPrompt: "You are a code reviewer..."
|
||||
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
goal: "You are a code reviewer..."
|
||||
capabilities: [code-review]
|
||||
procedure: "Review the implementation."
|
||||
output: "Approve or reject with comments."
|
||||
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
conditions:
|
||||
needsClarification:
|
||||
description: "Planner requests clarification from user"
|
||||
@@ -189,7 +198,7 @@ payload:
|
||||
condition: null
|
||||
```
|
||||
|
||||
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
||||
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
||||
- `condition` 引用 conditions 中的 key,`null` = fallback
|
||||
@@ -234,14 +243,14 @@ payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
||||
```
|
||||
|
||||
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
||||
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
||||
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
|
||||
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
|
||||
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||
- `agent` — 纯字符串,不是 CAS 节点
|
||||
|
||||
@@ -340,12 +349,12 @@ OPENROUTER_API_KEY=sk-or-...
|
||||
|
||||
```
|
||||
packages/
|
||||
├── cli-uwf/ # @uncaged/cli-uwf — uwf CLI(thread/workflow 命令)
|
||||
├── uwf-moderator/ # @uncaged/uwf-moderator — JSONata moderator 引擎
|
||||
├── uwf-agent-kit/ # @uncaged/uwf-agent-kit — Agent CLI 框架(含 extractor)
|
||||
├── uwf-agent-hermes/ # @uncaged/uwf-agent-hermes — uwf-hermes CLI
|
||||
├── uwf-agent-cursor/ # @uncaged/uwf-agent-cursor — uwf-cursor CLI
|
||||
└── uwf-protocol/ # @uncaged/uwf-protocol — 共享类型定义
|
||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
|
||||
├── workflow-moderator/ # @uncaged/workflow-moderator — JSONata moderator 引擎
|
||||
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
|
||||
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
||||
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
||||
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
|
||||
```
|
||||
|
||||
**外部依赖:**
|
||||
@@ -372,7 +381,7 @@ type ThreadId = string;
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||
type StepRecord = {
|
||||
role: string;
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
||||
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||
agent: string; // 实际使用的 agent 命令(纯字符串)
|
||||
};
|
||||
@@ -383,8 +392,11 @@ type StepRecord = {
|
||||
```typescript
|
||||
type RoleDefinition = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
goal: string;
|
||||
capabilities: string[];
|
||||
procedure: string;
|
||||
output: string;
|
||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
};
|
||||
|
||||
type Transition = {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: "analyze-topic"
|
||||
description: "Single-role topic analysis using four-phase role description"
|
||||
roles:
|
||||
analyst:
|
||||
description: "Analyzes a given topic and produces a structured summary"
|
||||
goal: |
|
||||
You are a research analyst with expertise in breaking down complex topics
|
||||
into clear, structured summaries. You think critically and cite key points.
|
||||
capabilities:
|
||||
- research
|
||||
- critical-thinking
|
||||
- structured-writing
|
||||
procedure: |
|
||||
Analyze the topic by:
|
||||
1. Identifying the main thesis or question
|
||||
2. Listing 3-5 key points with brief explanations
|
||||
3. Noting any counterarguments or caveats
|
||||
Keep your analysis concise (under 500 words).
|
||||
output: |
|
||||
Provide your analysis as markdown under the frontmatter.
|
||||
The frontmatter must include your structured findings.
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
thesis:
|
||||
type: string
|
||||
keyPoints:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [thesis, keyPoints]
|
||||
conditions: {}
|
||||
graph:
|
||||
$START:
|
||||
- role: "analyst"
|
||||
condition: null
|
||||
analyst:
|
||||
- role: "$END"
|
||||
condition: null
|
||||
@@ -3,8 +3,13 @@ description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
systemPrompt: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
||||
outputSchema:
|
||||
goal: "You are a planning agent. You analyze issues and create step-by-step plans."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
||||
output: "Output the plan summary and list of concrete steps."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
plan:
|
||||
@@ -16,8 +21,14 @@ roles:
|
||||
required: [plan, steps]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
systemPrompt: "You are a developer agent. Implement the plan."
|
||||
outputSchema:
|
||||
goal: "You are a developer agent. You implement code changes according to plans."
|
||||
capabilities:
|
||||
- file-edit
|
||||
- shell
|
||||
- testing
|
||||
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
||||
output: "List all files changed and provide a summary of the implementation."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
filesChanged:
|
||||
@@ -29,8 +40,13 @@ roles:
|
||||
required: [filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
systemPrompt: "You are a code reviewer. Review the implementation."
|
||||
outputSchema:
|
||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: "Review the implementation against the plan. Check for bugs, edge cases, and style."
|
||||
output: "Approve or reject with detailed comments explaining your decision."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
approved:
|
||||
@@ -41,7 +57,7 @@ roles:
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
import { createDocxDiffAgent } from "../src/agent.js";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
|
||||
describe("createDocxDiffAgent", () => {
|
||||
test("returns an AdapterFn (function)", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { ok, err } from "@uncaged/workflow-util";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { err, ok } from "@uncaged/workflow-util";
|
||||
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
|
||||
import { runDocxDiff } from "../src/runner.js";
|
||||
|
||||
@@ -74,7 +74,12 @@ describe("runDocxDiff", () => {
|
||||
test("exit 2: throws error", async () => {
|
||||
const dir = tempDir();
|
||||
const spawnFn = makeSpawn(
|
||||
err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult,
|
||||
err({
|
||||
kind: "non_zero_exit",
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "fatal error",
|
||||
}) as MockSpawnResult,
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-docx-diff",
|
||||
"version": "0.1.0",
|
||||
"files": ["src", "dist", "package.json"],
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import * as z from "zod/v4";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type { WriterMeta } from "@uncaged/workflow-template-document";
|
||||
import type * as z from "zod/v4";
|
||||
import { runDocxDiff } from "./runner.js";
|
||||
import type { DocxDiffAgentConfig } from "./types.js";
|
||||
|
||||
@@ -12,16 +17,10 @@ export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
|
||||
if (writerStep === undefined) throw new Error("differ: no writer step found");
|
||||
|
||||
const writerMeta = writerStep.meta as WriterMeta;
|
||||
if (writerMeta.mode !== "edit")
|
||||
throw new Error("differ: writer did not run in edit mode");
|
||||
if (writerMeta.mode !== "edit") throw new Error("differ: writer did not run in edit mode");
|
||||
|
||||
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
|
||||
const raw = await runDocxDiff(
|
||||
config,
|
||||
writerMeta.sourceDocx,
|
||||
writerMeta.outputDocx,
|
||||
diffDocx,
|
||||
);
|
||||
const raw = await runDocxDiff(config, writerMeta.sourceDocx, writerMeta.outputDocx, diffDocx);
|
||||
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
return { meta, childThread: null };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { DocxDiffAgentConfig } from "./types.js";
|
||||
|
||||
type SpawnCliFn = typeof spawnCli;
|
||||
@@ -8,8 +8,7 @@ type SpawnCliFn = typeof spawnCli;
|
||||
function throwSpawnError(e: SpawnCliError): never {
|
||||
if (e.kind === "non_zero_exit")
|
||||
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
|
||||
if (e.kind === "timeout")
|
||||
throw new Error("docx-diff: timed out");
|
||||
if (e.kind === "timeout") throw new Error("docx-diff: timed out");
|
||||
throw new Error(`docx-diff: spawn failed: ${e.message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
import { createOfficeAgent } from "../src/agent.js";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
|
||||
describe("createOfficeAgent", () => {
|
||||
test("returns an AdapterFn (function)", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { ok, err } from "@uncaged/workflow-util";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { err, ok } from "@uncaged/workflow-util";
|
||||
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
|
||||
import { editDocument, generateDocument } from "../src/runner.js";
|
||||
|
||||
@@ -123,7 +123,13 @@ describe("editDocument", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn),
|
||||
editDocument(
|
||||
{ outputDir: base, command: null, timeout: null },
|
||||
"te2",
|
||||
"edit",
|
||||
inputFile,
|
||||
spawnFn,
|
||||
),
|
||||
).rejects.toThrow("spawn failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-office",
|
||||
"version": "0.1.0",
|
||||
"files": ["src", "dist", "package.json"],
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as z from "zod/v4";
|
||||
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import type * as z from "zod/v4";
|
||||
import { editDocument, generateDocument } from "./runner.js";
|
||||
import type { OfficeAgentConfig } from "./types.js";
|
||||
|
||||
@@ -27,7 +32,10 @@ export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
|
||||
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
|
||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
|
||||
log("8FQKP3NV", `office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`);
|
||||
log(
|
||||
"8FQKP3NV",
|
||||
`office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`,
|
||||
);
|
||||
|
||||
let raw: string;
|
||||
if (inputDocx === null) {
|
||||
@@ -35,7 +43,11 @@ export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
|
||||
raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null });
|
||||
} else {
|
||||
const result = await editDocument(config, ctx.threadId, prompt, inputDocx);
|
||||
raw = JSON.stringify({ mode: "edit", outputDocx: result.outputDocx, sourceDocx: result.sourceDocx });
|
||||
raw = JSON.stringify({
|
||||
mode: "edit",
|
||||
outputDocx: result.outputDocx,
|
||||
sourceDocx: result.sourceDocx,
|
||||
});
|
||||
}
|
||||
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { OfficeAgentConfig } from "./types.js";
|
||||
|
||||
type SpawnCliFn = typeof spawnCli;
|
||||
@@ -9,8 +9,7 @@ type SpawnCliFn = typeof spawnCli;
|
||||
function throwSpawnError(e: SpawnCliError): never {
|
||||
if (e.kind === "non_zero_exit")
|
||||
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
|
||||
if (e.kind === "timeout")
|
||||
throw new Error("office-agent: timed out");
|
||||
if (e.kind === "timeout") throw new Error("office-agent: timed out");
|
||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow Dashboard</title>
|
||||
<script>
|
||||
(function () {
|
||||
(() => {
|
||||
var t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
|
||||
@@ -54,10 +54,14 @@ type CallExpression = {
|
||||
arguments: Array<AstExpression>;
|
||||
};
|
||||
|
||||
type AstExpression = Identifier | MemberExpression | CallExpression | {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type AstExpression =
|
||||
| Identifier
|
||||
| MemberExpression
|
||||
| CallExpression
|
||||
| {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type VariableDeclarator = {
|
||||
id: Identifier | null;
|
||||
@@ -258,15 +262,21 @@ function createLimitResolver(options: LimitLineOptions): (id: string) => Resolve
|
||||
}
|
||||
|
||||
function shouldProcess(id: string, options: LimitLineOptions): boolean {
|
||||
return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id));
|
||||
return (
|
||||
options.include.test(id) &&
|
||||
!id.includes("node_modules") &&
|
||||
(options.exclude === null || !options.exclude.test(id))
|
||||
);
|
||||
}
|
||||
|
||||
// --- Plugin ---
|
||||
|
||||
function viteLimitLinePlugin(
|
||||
userOptions: Partial<LimitLineOptions> = {},
|
||||
): Array<Plugin> {
|
||||
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
|
||||
function viteLimitLinePlugin(userOptions: Partial<LimitLineOptions> = {}): Array<Plugin> {
|
||||
const options: LimitLineOptions = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...userOptions,
|
||||
overrides: userOptions.overrides ?? [],
|
||||
};
|
||||
const resolve = createLimitResolver(options);
|
||||
|
||||
const rawCodeCache = new Map<string, string>();
|
||||
@@ -358,5 +368,5 @@ function viteLimitLinePlugin(
|
||||
];
|
||||
}
|
||||
|
||||
export { viteLimitLinePlugin };
|
||||
export type { LimitLineOptions, LimitLineOverride };
|
||||
export { viteLimitLinePlugin };
|
||||
|
||||
@@ -55,10 +55,7 @@ export function ResizablePanel({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative shrink-0", className)}
|
||||
style={{ ...style, width }}
|
||||
>
|
||||
<div className={cn("relative shrink-0", className)} style={{ ...style, width }}>
|
||||
{children}
|
||||
<div
|
||||
className="absolute top-0 -right-1 w-2 h-full cursor-col-resize z-10 group"
|
||||
|
||||
@@ -9,9 +9,7 @@ import type { DocumentMeta } from "../src/roles.js";
|
||||
|
||||
const documentModerator = tableToModerator(documentTable);
|
||||
|
||||
function makeCtx(
|
||||
steps: ModeratorContext<DocumentMeta>["steps"],
|
||||
): ModeratorContext<DocumentMeta> {
|
||||
function makeCtx(steps: ModeratorContext<DocumentMeta>["steps"]): ModeratorContext<DocumentMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
@@ -25,7 +23,11 @@ function writerGenerateStep(): RoleStep<DocumentMeta> {
|
||||
return {
|
||||
role: "writer",
|
||||
contentHash: "STUBHASHWRITER001",
|
||||
meta: { mode: "generate", outputDocx: "/out/output.docx", sourceDocx: null } satisfies WriterMeta,
|
||||
meta: {
|
||||
mode: "generate",
|
||||
outputDocx: "/out/output.docx",
|
||||
sourceDocx: null,
|
||||
} satisfies WriterMeta,
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
@@ -35,7 +37,11 @@ function writerEditStep(): RoleStep<DocumentMeta> {
|
||||
return {
|
||||
role: "writer",
|
||||
contentHash: "STUBHASHWRITER002",
|
||||
meta: { mode: "edit", outputDocx: "/out/modified.docx", sourceDocx: "/out/original.docx" } satisfies WriterMeta,
|
||||
meta: {
|
||||
mode: "edit",
|
||||
outputDocx: "/out/modified.docx",
|
||||
sourceDocx: "/out/original.docx",
|
||||
} satisfies WriterMeta,
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-document",
|
||||
"version": "0.1.0",
|
||||
"files": ["src", "dist", "package.json"],
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"test": "bun run --filter '*' test",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||
"release": "bun run build && bun test && node scripts/publish-all.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/cli-uwf",
|
||||
"version": "0.1.0",
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.5.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
@@ -11,11 +11,11 @@
|
||||
"uwf": "./src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^",
|
||||
"@uncaged/uwf-moderator": "workspace:^",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"@uncaged/json-cas-fs": "^0.4.0",
|
||||
"@uncaged/workflow-agent-kit": "workspace:^",
|
||||
"@uncaged/workflow-moderator": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"commander": "^14.0.3",
|
||||
"dotenv": "^16.6.1",
|
||||
@@ -0,0 +1,150 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { cmdSetup, validateModel } from "../commands/setup.js";
|
||||
|
||||
describe("validateModel", () => {
|
||||
const BASE_URL = "https://api.example.com/v1";
|
||||
const API_KEY = "sk-test-key";
|
||||
const MODEL = "test-model";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("success path — returns ok on 200", async () => {
|
||||
const mockFetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result).toEqual({ ok: true, value: undefined });
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const [url, opts] = mockFetch.mock.calls[0]!;
|
||||
expect(url).toBe(`${BASE_URL}/chat/completions`);
|
||||
expect((opts as RequestInit).headers).toEqual(
|
||||
expect.objectContaining({ Authorization: `Bearer ${API_KEY}` }),
|
||||
);
|
||||
const body = JSON.parse((opts as RequestInit).body as string);
|
||||
expect(body).toEqual({
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("HTTP 401 — returns error containing 401", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
||||
);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("401");
|
||||
}
|
||||
});
|
||||
|
||||
test("HTTP 404 — returns error containing 404", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Not Found", { status: 404, statusText: "Not Found" }),
|
||||
);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("404");
|
||||
}
|
||||
});
|
||||
|
||||
test("network timeout — returns error mentioning timeout", async () => {
|
||||
const err = new DOMException("signal timed out", "AbortError");
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(err);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.toLowerCase()).toMatch(/timeout|timed out/);
|
||||
}
|
||||
});
|
||||
|
||||
test("network error (DNS/connection) — returns error mentioning connectivity", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.toLowerCase()).toMatch(/connect|reach|network/);
|
||||
}
|
||||
});
|
||||
|
||||
test("request body correctness", async () => {
|
||||
const mockFetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
await validateModel(BASE_URL, API_KEY, "my-special-model");
|
||||
|
||||
const body = JSON.parse((mockFetch.mock.calls[0]![1] as RequestInit).body as string);
|
||||
expect(body).toEqual({
|
||||
model: "my-special-model",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdSetup with validation", () => {
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-validate-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const setupArgs = () => ({
|
||||
provider: "testprovider",
|
||||
baseUrl: "https://api.test.com/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "test-model",
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
test("includes validation result on success", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await cmdSetup(setupArgs());
|
||||
|
||||
expect(result.validation).toEqual({ ok: true, value: undefined });
|
||||
// Config files should still be written
|
||||
expect(result.configPath).toBeTruthy();
|
||||
expect(result.envPath).toBeTruthy();
|
||||
});
|
||||
|
||||
test("includes validation failure — config still saved", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
||||
);
|
||||
|
||||
const result = await cmdSetup(setupArgs());
|
||||
|
||||
expect(result.validation).toBeDefined();
|
||||
expect((result.validation as { ok: boolean }).ok).toBe(false);
|
||||
// Config files should still be written despite validation failure
|
||||
expect(result.configPath).toBeTruthy();
|
||||
expect(result.envPath).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const CLI_PATH = join(import.meta.dirname, "..", "cli.js");
|
||||
|
||||
function runCli(args: string[]): { stdout: string; stderr: string; exitCode: number } {
|
||||
try {
|
||||
const stdout = execFileSync("bun", ["run", CLI_PATH, ...args], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, WORKFLOW_STORAGE_ROOT: "/tmp/uwf-test-nonexistent" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return { stdout, stderr: "", exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; status?: number };
|
||||
return {
|
||||
stdout: err.stdout ?? "",
|
||||
stderr: err.stderr ?? "",
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("thread step --count CLI parsing", () => {
|
||||
test("--help shows -c/--count option", () => {
|
||||
const result = runCli(["thread", "step", "--help"]);
|
||||
expect(result.stdout).toContain("--count");
|
||||
expect(result.stdout).toContain("-c");
|
||||
});
|
||||
|
||||
test("description says 'one or more steps'", () => {
|
||||
const result = runCli(["thread", "step", "--help"]);
|
||||
expect(result.stdout).toContain("one or more steps");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdThreadStep count logic", () => {
|
||||
test("count=0 fails with validation error", () => {
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "0"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("positive integer");
|
||||
});
|
||||
|
||||
test("negative count fails with validation error", () => {
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "-1"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("positive integer");
|
||||
});
|
||||
|
||||
test("non-integer count fails with validation error", () => {
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "1.5"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("positive integer");
|
||||
});
|
||||
|
||||
test("count=1 is the default (no -c flag)", () => {
|
||||
// Without -c, it should attempt to run 1 step (failing on missing thread, not on count validation)
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
// Should NOT contain "positive integer" error — should fail on thread lookup instead
|
||||
expect(result.stderr).not.toContain("positive integer");
|
||||
});
|
||||
|
||||
test("count=3 passes validation (fails on thread lookup)", () => {
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "3"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
// Should NOT contain "positive integer" error — should fail on thread/storage lookup
|
||||
expect(result.stderr).not.toContain("positive integer");
|
||||
});
|
||||
});
|
||||
+6
-3
@@ -3,7 +3,7 @@ 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/uwf-protocol";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
cmdThreadRead,
|
||||
@@ -211,8 +211,11 @@ describe("cmdThreadRead ### Content section", () => {
|
||||
roles: {
|
||||
writer: {
|
||||
description: "Write",
|
||||
systemPrompt: "You are a writer.",
|
||||
outputSchema: "placeholder00" as CasRef,
|
||||
goal: "You are a writer.",
|
||||
capabilities: [],
|
||||
procedure: "Write content as requested.",
|
||||
output: "Summarize what was written.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import type { ThreadId } from "@uncaged/uwf-protocol";
|
||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { Command } from "commander";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import {
|
||||
cmdCasGet,
|
||||
cmdCasHas,
|
||||
cmdCasPut,
|
||||
cmdCasPutText,
|
||||
cmdCasRefs,
|
||||
cmdCasReindex,
|
||||
cmdCasSchemaGet,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
cmdCasWalk,
|
||||
} from "./commands/cas.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import { cmdSkillCli } from "./commands/skill.js";
|
||||
import {
|
||||
cmdThreadFork,
|
||||
cmdThreadKill,
|
||||
@@ -45,7 +47,12 @@ function runAction(action: () => Promise<void>): void {
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name("uwf").description("Stateless workflow CLI");
|
||||
// eslint-disable-next-line -- dynamic import for version
|
||||
const pkg = await import("../package.json", { with: { type: "json" } });
|
||||
program
|
||||
.name("uwf")
|
||||
.description("Stateless workflow CLI")
|
||||
.version(pkg.default.version, "-V, --version");
|
||||
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||
|
||||
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
||||
@@ -80,7 +87,7 @@ workflow
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowList(storageRoot);
|
||||
const result = await cmdWorkflowList(storageRoot, process.cwd());
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
@@ -95,22 +102,28 @@ thread
|
||||
.action((workflow: string, opts: { prompt: string }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
|
||||
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt, process.cwd());
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("step")
|
||||
.description("Execute one step")
|
||||
.description("Execute one or more steps")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("--agent <cmd>", "Override agent command")
|
||||
.action((threadId: string, opts: { agent: string | undefined }) => {
|
||||
.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 result = await cmdThreadStep(storageRoot, threadId, agentOverride);
|
||||
writeOutput(result);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,6 +228,15 @@ thread
|
||||
});
|
||||
});
|
||||
|
||||
const skill = program.command("skill").description("Built-in skill references for agents");
|
||||
|
||||
skill
|
||||
.command("cli")
|
||||
.description("Print a markdown reference of all uwf commands")
|
||||
.action(() => {
|
||||
console.log(cmdSkillCli());
|
||||
});
|
||||
|
||||
program
|
||||
.command("setup")
|
||||
.description("Configure provider, model, and agent")
|
||||
@@ -280,6 +302,17 @@ cas
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("put-text")
|
||||
.description("Store a plain text string, print its hash")
|
||||
.argument("<text>", "Text content to store")
|
||||
.action((text: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasPutText(storageRoot, text));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("has")
|
||||
.description("Check if a hash exists")
|
||||
@@ -1,10 +1,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import type { JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, putSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
import { TEXT_SCHEMA } from "../schemas.js";
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function openStore(storageRoot: string): Store {
|
||||
@@ -53,18 +55,12 @@ export async function cmdCasPut(
|
||||
return { hash };
|
||||
}
|
||||
|
||||
export async function cmdCasHas(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ exists: boolean }> {
|
||||
export async function cmdCasHas(storageRoot: string, hash: string): Promise<{ exists: boolean }> {
|
||||
const store = openStore(storageRoot);
|
||||
return { exists: store.has(hash) };
|
||||
}
|
||||
|
||||
export async function cmdCasRefs(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ refs: string[] }> {
|
||||
export async function cmdCasRefs(storageRoot: string, hash: string): Promise<{ refs: string[] }> {
|
||||
const store = openStore(storageRoot);
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
@@ -73,10 +69,7 @@ export async function cmdCasRefs(
|
||||
return { refs: refs(store, node) };
|
||||
}
|
||||
|
||||
export async function cmdCasWalk(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ hashes: string[] }> {
|
||||
export async function cmdCasWalk(storageRoot: string, hash: string): Promise<{ hashes: string[] }> {
|
||||
const store = openStore(storageRoot);
|
||||
const result: string[] = [];
|
||||
walk(store, hash, (h) => {
|
||||
@@ -90,9 +83,7 @@ export type SchemaListEntry = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export async function cmdCasSchemaList(
|
||||
storageRoot: string,
|
||||
): Promise<SchemaListEntry[]> {
|
||||
export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListEntry[]> {
|
||||
const store = openStore(storageRoot);
|
||||
const metaHash = await bootstrap(store);
|
||||
const entries: SchemaListEntry[] = [];
|
||||
@@ -115,9 +106,7 @@ export async function cmdCasSchemaList(
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function cmdCasReindex(
|
||||
storageRoot: string,
|
||||
): Promise<{ status: string }> {
|
||||
export async function cmdCasReindex(storageRoot: string): Promise<{ status: string }> {
|
||||
const indexDir = join(storageRoot, "cas", "_index");
|
||||
const { rmSync } = await import("node:fs");
|
||||
rmSync(indexDir, { recursive: true, force: true });
|
||||
@@ -126,10 +115,7 @@ export async function cmdCasReindex(
|
||||
return { status: "reindexed" };
|
||||
}
|
||||
|
||||
export async function cmdCasSchemaGet(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<unknown> {
|
||||
export async function cmdCasSchemaGet(storageRoot: string, hash: string): Promise<unknown> {
|
||||
const store = openStore(storageRoot);
|
||||
const schema = getSchema(store, hash);
|
||||
if (schema === null) {
|
||||
@@ -137,3 +123,10 @@ export async function cmdCasSchemaGet(
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
export async function cmdCasPutText(storageRoot: string, text: string): Promise<{ hash: string }> {
|
||||
const store = openStore(storageRoot);
|
||||
const typeHash = await putSchema(store, TEXT_SCHEMA);
|
||||
const hash = await store.put(typeHash, text);
|
||||
return { hash };
|
||||
}
|
||||
+89
-19
@@ -1,10 +1,45 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { existsSync, mkdirSync, readFileSync, 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";
|
||||
import type { Result } from "@uncaged/workflow-util";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import { stringify, parse } from "yaml";
|
||||
/**
|
||||
* Send a minimal chat completion request to verify the model is reachable.
|
||||
* Returns ok on 2xx, error with reason string otherwise.
|
||||
*/
|
||||
export async function validateModel(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
): Promise<Result<void, string>> {
|
||||
try {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `HTTP ${res.status} ${res.statusText}` };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return { ok: false, error: "Request timed out — model endpoint unreachable" };
|
||||
}
|
||||
return { ok: false, error: `Network error — could not reach endpoint (${String(err)})` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset provider list — embedded to avoid runtime YAML loading dependency.
|
||||
@@ -17,10 +52,18 @@ const PRESET_PROVIDERS = [
|
||||
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
|
||||
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
|
||||
// China
|
||||
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
|
||||
{
|
||||
name: "dashscope",
|
||||
label: "DashScope (Alibaba)",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
},
|
||||
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
|
||||
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
|
||||
{
|
||||
name: "volcengine",
|
||||
label: "Volcengine (ByteDance)",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
|
||||
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
|
||||
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
|
||||
@@ -98,21 +141,27 @@ function apiKeyEnvName(providerName: string): string {
|
||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||
*/
|
||||
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
|
||||
const providers = (typeof existing.providers === "object" && existing.providers !== null
|
||||
? { ...(existing.providers as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
const providers = (
|
||||
typeof existing.providers === "object" && existing.providers !== null
|
||||
? { ...(existing.providers as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const envName = apiKeyEnvName(args.provider);
|
||||
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
|
||||
|
||||
const models = (typeof existing.models === "object" && existing.models !== null
|
||||
? { ...(existing.models as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
const models = (
|
||||
typeof existing.models === "object" && existing.models !== null
|
||||
? { ...(existing.models as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
models.default = { provider: args.provider, name: args.model };
|
||||
|
||||
const agents = (typeof existing.agents === "object" && existing.agents !== null
|
||||
? { ...(existing.agents as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
const agents = (
|
||||
typeof existing.agents === "object" && existing.agents !== null
|
||||
? { ...(existing.agents as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const agentName = args.agent ?? "hermes";
|
||||
if (Object.keys(agents).length === 0) {
|
||||
@@ -150,12 +199,16 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
|
||||
envData[envName] = args.apiKey;
|
||||
saveEnvFile(envPath, envData);
|
||||
|
||||
// Validate model connectivity
|
||||
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
|
||||
|
||||
return {
|
||||
configPath,
|
||||
envPath,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
defaultAgent: merged.defaultAgent,
|
||||
validation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,8 +264,12 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { data?: { id: string }[] };
|
||||
if (!Array.isArray(body.data)) return [];
|
||||
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
|
||||
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
|
||||
const NON_CHAT =
|
||||
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
|
||||
return body.data
|
||||
.map((m) => m.id)
|
||||
.filter((id) => !NON_CHAT.test(id))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -311,7 +368,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
|
||||
console.log(` → ${providerName}/${model}\n`);
|
||||
|
||||
await cmdSetup({
|
||||
const setupResult = await cmdSetup({
|
||||
provider: providerName,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
@@ -319,6 +376,19 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
// 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -0,0 +1 @@
|
||||
export { generateCliReference as cmdSkillCli } from "@uncaged/workflow-util";
|
||||
+78
-7
@@ -1,8 +1,9 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||
import { evaluate } from "@uncaged/uwf-moderator";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
|
||||
import { evaluate } from "@uncaged/workflow-moderator";
|
||||
import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
@@ -21,24 +22,27 @@ import type {
|
||||
ThreadStepsOutput,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { stringify } from "yaml";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
discoverProjectWorkflows,
|
||||
findThreadInHistory,
|
||||
loadThreadHistory,
|
||||
loadThreadsIndex,
|
||||
loadWorkflowRegistry,
|
||||
resolveProjectWorkflowFile,
|
||||
resolveWorkflowHash,
|
||||
saveThreadsIndex,
|
||||
type ThreadHistoryLine,
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { isCasRef } from "../validate.js";
|
||||
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
|
||||
import { materializeWorkflowPayload } from "./workflow.js";
|
||||
|
||||
const END_ROLE = "$END";
|
||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||
@@ -66,11 +70,55 @@ function fail(message: string): never {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise<CasRef> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
fail(`project workflow file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parse(text) as unknown;
|
||||
} catch (e) {
|
||||
fail(`invalid YAML in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const payload = parseWorkflowPayload(raw);
|
||||
if (payload === null) {
|
||||
fail(`invalid workflow YAML in ${filePath}: expected WorkflowPayload shape`);
|
||||
}
|
||||
|
||||
const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
|
||||
if (filenameError !== null) {
|
||||
fail(filenameError);
|
||||
}
|
||||
|
||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
|
||||
const stored = uwf.store.get(hash);
|
||||
if (stored === null || !validate(uwf.store, stored)) {
|
||||
fail("stored local workflow failed schema validation");
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function resolveWorkflowCasRef(
|
||||
uwf: UwfStore,
|
||||
storageRoot: string,
|
||||
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);
|
||||
}
|
||||
|
||||
// Global registry fallback
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
const hash = resolveWorkflowHash(registry, workflowId);
|
||||
if (!isCasRef(hash)) {
|
||||
@@ -114,9 +162,10 @@ export async function cmdThreadStart(
|
||||
storageRoot: string,
|
||||
workflowId: string,
|
||||
prompt: string,
|
||||
projectRoot: string,
|
||||
): Promise<StartOutput> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
|
||||
|
||||
const threadId = generateUlid(Date.now()) as ThreadId;
|
||||
const startPayload: StartNodePayload = {
|
||||
@@ -500,7 +549,8 @@ function formatThreadReadMarkdown(options: {
|
||||
];
|
||||
const roleDef = workflow.roles[item.payload.role];
|
||||
if (roleDef) {
|
||||
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
|
||||
const prompt = roleDef.goal;
|
||||
stepLines.push("", "### Prompt", "", prompt);
|
||||
}
|
||||
if (item.payload.detail) {
|
||||
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
||||
@@ -623,6 +673,27 @@ export async function cmdThreadStep(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
count: number,
|
||||
): Promise<StepOutput[]> {
|
||||
if (count < 1 || !Number.isInteger(count)) {
|
||||
fail(`--count must be a positive integer, got: ${count}`);
|
||||
}
|
||||
|
||||
const results: StepOutput[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride);
|
||||
results.push(result);
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function cmdThreadStepOnce(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
+64
-17
@@ -2,22 +2,31 @@ import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
import type {
|
||||
CasRef,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import {
|
||||
createUwfStore,
|
||||
discoverProjectWorkflows,
|
||||
findRegistryName,
|
||||
loadWorkflowRegistry,
|
||||
resolveWorkflowHash,
|
||||
saveWorkflowRegistry,
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { parseWorkflowPayload } from "../validate.js";
|
||||
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
|
||||
|
||||
export type WorkflowOrigin = "local" | "global";
|
||||
|
||||
export type WorkflowListEntry = {
|
||||
name: string;
|
||||
hash: CasRef;
|
||||
origin: WorkflowOrigin;
|
||||
};
|
||||
|
||||
export type WorkflowPutOutput = {
|
||||
@@ -42,35 +51,49 @@ function isJsonSchema(value: unknown): value is JSONSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function resolveOutputSchemaRef(
|
||||
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
|
||||
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
|
||||
const result: Record<string, Transition[]> = {};
|
||||
for (const [node, transitions] of Object.entries(graph)) {
|
||||
result[node] = transitions.map((t) => ({
|
||||
role: t.role,
|
||||
condition: t.condition ?? null,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolveFrontmatterRef(
|
||||
uwf: UwfStore,
|
||||
roleName: string,
|
||||
outputSchema: unknown,
|
||||
frontmatter: unknown,
|
||||
): Promise<CasRef> {
|
||||
if (!isJsonSchema(outputSchema)) {
|
||||
fail(`role "${roleName}": outputSchema must be a JSON Schema object`);
|
||||
if (!isJsonSchema(frontmatter)) {
|
||||
fail(`role "${roleName}": frontmatter must be a JSON Schema object`);
|
||||
}
|
||||
const schema: JSONSchema = outputSchema.title === undefined
|
||||
? { ...outputSchema, title: roleName }
|
||||
: outputSchema;
|
||||
const schema: JSONSchema =
|
||||
frontmatter.title === undefined ? { ...frontmatter, title: roleName } : frontmatter;
|
||||
return putSchema(uwf.store, schema);
|
||||
}
|
||||
|
||||
async function materializeWorkflowPayload(
|
||||
export async function materializeWorkflowPayload(
|
||||
uwf: UwfStore,
|
||||
raw: WorkflowPayload,
|
||||
): Promise<WorkflowPayload> {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
for (const [roleName, role] of Object.entries(raw.roles)) {
|
||||
const outputSchema = await resolveOutputSchemaRef(
|
||||
const frontmatter = await resolveFrontmatterRef(
|
||||
uwf,
|
||||
`${raw.name}.${roleName}`,
|
||||
role.outputSchema,
|
||||
role.frontmatter,
|
||||
);
|
||||
roles[roleName] = {
|
||||
description: role.description,
|
||||
systemPrompt: role.systemPrompt,
|
||||
outputSchema,
|
||||
goal: role.goal,
|
||||
capabilities: role.capabilities,
|
||||
procedure: role.procedure,
|
||||
output: role.output,
|
||||
frontmatter,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -78,7 +101,7 @@ async function materializeWorkflowPayload(
|
||||
description: raw.description,
|
||||
roles,
|
||||
conditions: raw.conditions,
|
||||
graph: raw.graph,
|
||||
graph: normalizeGraph(raw.graph),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,6 +128,11 @@ export async function cmdWorkflowPut(
|
||||
fail("invalid workflow YAML: expected WorkflowPayload shape");
|
||||
}
|
||||
|
||||
const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
|
||||
if (filenameError !== null) {
|
||||
fail(filenameError);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||
|
||||
@@ -147,7 +175,26 @@ export async function cmdWorkflowShow(
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
|
||||
export async function cmdWorkflowList(
|
||||
storageRoot: string,
|
||||
projectRoot: string,
|
||||
): Promise<WorkflowListEntry[]> {
|
||||
const localEntries = await discoverProjectWorkflows(projectRoot);
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
|
||||
|
||||
const result: WorkflowListEntry[] = [];
|
||||
const localNames = new Set<string>();
|
||||
|
||||
for (const entry of localEntries) {
|
||||
localNames.add(entry.name);
|
||||
result.push({ name: entry.name, hash: "(local)", origin: "local" });
|
||||
}
|
||||
|
||||
for (const [name, hash] of Object.entries(registry)) {
|
||||
if (!localNames.has(name)) {
|
||||
result.push({ name, hash, origin: "global" });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import {
|
||||
START_NODE_SCHEMA,
|
||||
STEP_NODE_SCHEMA,
|
||||
WORKFLOW_SCHEMA,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
|
||||
|
||||
export const TEXT_SCHEMA = { type: "string" as const };
|
||||
|
||||
export type UwfSchemaHashes = {
|
||||
workflow: Hash;
|
||||
startNode: Hash;
|
||||
stepNode: Hash;
|
||||
text: Hash;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -17,10 +16,11 @@ export type UwfSchemaHashes = {
|
||||
* Idempotent: safe to call on every CLI invocation.
|
||||
*/
|
||||
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
|
||||
const [workflow, startNode, stepNode] = await Promise.all([
|
||||
const [workflow, startNode, stepNode, text] = await Promise.all([
|
||||
putSchema(store, WORKFLOW_SCHEMA),
|
||||
putSchema(store, START_NODE_SCHEMA),
|
||||
putSchema(store, STEP_NODE_SCHEMA),
|
||||
putSchema(store, TEXT_SCHEMA),
|
||||
]);
|
||||
return { workflow, startNode, stepNode };
|
||||
return { workflow, startNode, stepNode, text };
|
||||
}
|
||||
@@ -1,16 +1,54 @@
|
||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type { BootstrapCapableStore, Hash } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol";
|
||||
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/workflow-protocol";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
|
||||
|
||||
export type WorkflowRegistry = Record<string, CasRef>;
|
||||
|
||||
/** A workflow entry discovered from the project-local .workflows/ directory. */
|
||||
export type ProjectWorkflowEntry = {
|
||||
/** Workflow name (from YAML `name` field, equals filename stem). */
|
||||
name: string;
|
||||
/** Absolute path to the YAML file. */
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan `<projectRoot>/.workflows/*.yaml` (non-recursive) and return discovered entries.
|
||||
* Returns an empty array if the directory does not exist.
|
||||
*/
|
||||
export async function discoverProjectWorkflows(
|
||||
projectRoot: string,
|
||||
): Promise<ProjectWorkflowEntry[]> {
|
||||
const dir = join(projectRoot, ".workflows");
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const result: ProjectWorkflowEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".yaml") && !entry.endsWith(".yml")) {
|
||||
continue;
|
||||
}
|
||||
const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4);
|
||||
result.push({ name: stem, filePath: join(dir, entry) });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||
export function getDefaultStorageRoot(): string {
|
||||
return join(homedir(), ".uncaged", "workflow");
|
||||
@@ -54,7 +92,7 @@ export type ThreadHistoryLine = ThreadListItem & {
|
||||
|
||||
export type UwfStore = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
store: BootstrapCapableStore;
|
||||
schemas: UwfSchemaHashes;
|
||||
};
|
||||
|
||||
@@ -104,6 +142,22 @@ export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): Cas
|
||||
return registry[id] !== undefined ? registry[id] : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workflow name to a project-local YAML file path.
|
||||
* Returns null if the name is not found in the local entries.
|
||||
*/
|
||||
export function resolveProjectWorkflowFile(
|
||||
localEntries: ProjectWorkflowEntry[],
|
||||
name: string,
|
||||
): string | null {
|
||||
for (const entry of localEntries) {
|
||||
if (entry.name === name) {
|
||||
return entry.filePath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
|
||||
for (const [name, h] of Object.entries(registry)) {
|
||||
if (h === hash) {
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CasRef, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
import { basename } from "node:path";
|
||||
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
|
||||
@@ -14,10 +15,18 @@ function isRoleDefinition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const outputSchema = value.outputSchema;
|
||||
const schemaOk = isRecord(outputSchema) && typeof outputSchema.type === "string";
|
||||
const frontmatter = value.frontmatter;
|
||||
const frontmatterOk = isRecord(frontmatter) && typeof frontmatter.type === "string";
|
||||
const capabilities = value.capabilities;
|
||||
const capabilitiesOk =
|
||||
Array.isArray(capabilities) && capabilities.every((c) => typeof c === "string");
|
||||
return (
|
||||
typeof value.description === "string" && typeof value.systemPrompt === "string" && schemaOk
|
||||
typeof value.description === "string" &&
|
||||
typeof value.goal === "string" &&
|
||||
capabilitiesOk &&
|
||||
typeof value.procedure === "string" &&
|
||||
typeof value.output === "string" &&
|
||||
frontmatterOk
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +42,10 @@ function isTransition(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
const condition = value.condition;
|
||||
return typeof value.role === "string" && (condition === null || typeof condition === "string");
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
(condition === null || condition === undefined || typeof condition === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
|
||||
@@ -52,6 +64,33 @@ function isGraph(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the expected workflow name from a file path (stem without extension).
|
||||
* Returns the stem for `.yaml` / `.yml` files.
|
||||
*/
|
||||
export function workflowNameFromPath(filePath: string): string {
|
||||
const base = basename(filePath);
|
||||
if (base.endsWith(".yaml")) return base.slice(0, -5);
|
||||
if (base.endsWith(".yml")) return base.slice(0, -4);
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the `name` field in a parsed payload matches the expected name
|
||||
* derived from the file path. Returns an error message string on mismatch,
|
||||
* or null when the names are consistent.
|
||||
*/
|
||||
export function checkWorkflowFilenameConsistency(
|
||||
filePath: string,
|
||||
payload: WorkflowPayload,
|
||||
): string | null {
|
||||
const expected = workflowNameFromPath(filePath);
|
||||
if (payload.name !== expected) {
|
||||
return `workflow name mismatch: file "${basename(filePath)}" implies name "${expected}" but YAML declares name "${payload.name}"`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
|
||||
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (!isRecord(raw)) {
|
||||
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../uwf-protocol" },
|
||||
{ "path": "../uwf-moderator" },
|
||||
{ "path": "../uwf-agent-kit" }
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-moderator" },
|
||||
{ "path": "../workflow-agent-kit" }
|
||||
]
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||
|
||||
describe("buildOutputFormatInstruction", () => {
|
||||
test("always includes the frontmatter example block", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("---");
|
||||
expect(result).toContain("status: done");
|
||||
expect(result).toContain("confidence:");
|
||||
expect(result).toContain("scope: role");
|
||||
});
|
||||
|
||||
test("always marks frontmatter as the primary deliverable", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("primary deliverable");
|
||||
});
|
||||
|
||||
test("lists fields from a flat object schema", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string" },
|
||||
confidence: { type: "number" },
|
||||
},
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`status`");
|
||||
expect(result).toContain("`confidence`");
|
||||
});
|
||||
|
||||
test("lists union of fields from an anyOf schema", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { alpha: { type: "string" } },
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { beta: { type: "number" } },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`alpha`");
|
||||
expect(result).toContain("`beta`");
|
||||
});
|
||||
|
||||
test("lists union of fields from a oneOf schema", () => {
|
||||
const schema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { foo: { type: "string" } },
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { bar: { type: "boolean" } },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`foo`");
|
||||
expect(result).toContain("`bar`");
|
||||
});
|
||||
|
||||
test("falls back gracefully for a non-object schema with no properties", () => {
|
||||
const result = buildOutputFormatInstruction({ type: "string" });
|
||||
expect(result).toContain("schema fields will be extracted automatically");
|
||||
});
|
||||
|
||||
test("does not list a field more than once for a union with overlapping keys", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{ type: "object", properties: { shared: { type: "string" } } },
|
||||
{ type: "object", properties: { shared: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
const matches = [...result.matchAll(/`shared`/g)];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
|
||||
test("includes focus reminder about role scope", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("Focus exclusively on YOUR role");
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
|
||||
/**
|
||||
* Extract top-level property names from a JSON Schema object.
|
||||
*
|
||||
* Handles:
|
||||
* - Object schemas with a `properties` key
|
||||
* - Union schemas via `anyOf` / `oneOf` — union of all variant property names
|
||||
*
|
||||
* Returns an empty array for schemas with no inspectable property definitions.
|
||||
*/
|
||||
function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
if (typeof schema.properties === "object" && schema.properties !== null) {
|
||||
return Object.keys(schema.properties as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const unionKey = Array.isArray(schema.anyOf)
|
||||
? "anyOf"
|
||||
: Array.isArray(schema.oneOf)
|
||||
? "oneOf"
|
||||
: null;
|
||||
|
||||
if (unionKey !== null) {
|
||||
const variants = schema[unionKey] as JSONSchema[];
|
||||
const fieldSet = new Set<string>();
|
||||
for (const variant of variants) {
|
||||
for (const field of extractSchemaFields(variant)) {
|
||||
fieldSet.add(field);
|
||||
}
|
||||
}
|
||||
return [...fieldSet];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a concise output format instruction block for an agent role.
|
||||
*
|
||||
* The instruction describes the expected frontmatter markdown format and lists
|
||||
* the meta fields derived from the JSON Schema. It is prepended to the agent's
|
||||
* system prompt so the deliverable format is the first thing the agent sees.
|
||||
*/
|
||||
export function buildOutputFormatInstruction(schema: JSONSchema): string {
|
||||
const fields = extractSchemaFields(schema);
|
||||
|
||||
const fieldList =
|
||||
fields.length > 0
|
||||
? fields.map((f) => ` - \`${f}\``).join("\n")
|
||||
: " (schema fields will be extracted automatically)";
|
||||
|
||||
return `## Deliverable Format
|
||||
|
||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
||||
|
||||
\`\`\`
|
||||
---
|
||||
status: done # done | needs_input | in_progress | failed
|
||||
next: <role-name> # suggested next role, or omit
|
||||
confidence: 0.9 # 0.0–1.0, your self-assessed confidence
|
||||
artifacts: # list of file paths or CAS hashes you produced
|
||||
- path/to/file.ts
|
||||
scope: role # role | thread
|
||||
---
|
||||
|
||||
... your markdown work here ...
|
||||
\`\`\`
|
||||
|
||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||
Your meta output must satisfy these fields:
|
||||
|
||||
${fieldList}
|
||||
|
||||
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { validate } from "@uncaged/json-cas";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import type { CasRef } from "@uncaged/uwf-protocol";
|
||||
import { parseFrontmatterMarkdown, validateFrontmatter } from "@uncaged/workflow-util";
|
||||
|
||||
export type FrontmatterFastPathResult = {
|
||||
body: string;
|
||||
outputHash: CasRef;
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to satisfy `outputSchema` from frontmatter fields alone.
|
||||
*
|
||||
* Returns a result containing the stored CAS hash and stripped body on success,
|
||||
* or `null` when frontmatter is absent, invalid, or does not satisfy the schema.
|
||||
* Never throws.
|
||||
*
|
||||
* The candidate object is put into the real CAS store (idempotent content-addressed
|
||||
* write) and validated against the output schema. If validation fails the node
|
||||
* is orphaned — it will be GC'd on the next collection pass.
|
||||
*/
|
||||
export async function tryFrontmatterFastPath(
|
||||
raw: string,
|
||||
outputSchema: CasRef,
|
||||
store: Store,
|
||||
): Promise<FrontmatterFastPathResult | null> {
|
||||
const { frontmatter, body } = parseFrontmatterMarkdown(raw);
|
||||
|
||||
if (frontmatter === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationErrors = validateFrontmatter(frontmatter);
|
||||
if (validationErrors.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate: Record<string, unknown> = {
|
||||
status: frontmatter.status,
|
||||
next: frontmatter.next,
|
||||
confidence: frontmatter.confidence,
|
||||
artifacts: [...frontmatter.artifacts],
|
||||
scope: frontmatter.scope,
|
||||
};
|
||||
|
||||
let outputHash: CasRef;
|
||||
let node: ReturnType<Store["get"]>;
|
||||
|
||||
try {
|
||||
outputHash = await store.put(outputSchema, candidate);
|
||||
node = store.get(outputHash);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node === null || !validate(store, node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { body, outputHash };
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
|
||||
import { evaluate } from "../src/evaluate.js";
|
||||
|
||||
const solveIssueWorkflow: WorkflowPayload = {
|
||||
name: "solve-issue",
|
||||
description: "End-to-end issue resolution",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Creates implementation plan",
|
||||
systemPrompt: "You are a planning agent...",
|
||||
outputSchema: "5GWKR8TN1V3JA",
|
||||
},
|
||||
developer: {
|
||||
description: "Implements code changes",
|
||||
systemPrompt: "You are a developer agent...",
|
||||
outputSchema: "8CNWT4KR6D1HV",
|
||||
},
|
||||
reviewer: {
|
||||
description: "Reviews code changes",
|
||||
systemPrompt: "You are a code reviewer...",
|
||||
outputSchema: "1VPBG9SM5E7WK",
|
||||
},
|
||||
},
|
||||
conditions: {
|
||||
needsClarification: {
|
||||
description: "Planner requests clarification from user",
|
||||
expression: "$exists(steps[-1].output.needsClarification)",
|
||||
},
|
||||
notApproved: {
|
||||
description: "Reviewer rejected the implementation",
|
||||
expression: "steps[-1].output.approved = false",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [{ role: "planner", condition: null }],
|
||||
planner: [
|
||||
{ role: "developer", condition: "needsClarification" },
|
||||
{ role: "$END", condition: null },
|
||||
],
|
||||
developer: [{ role: "reviewer", condition: null }],
|
||||
reviewer: [
|
||||
{ role: "developer", condition: "notApproved" },
|
||||
{ role: "$END", condition: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
||||
return {
|
||||
start: {
|
||||
workflow: "4KNM2PXR3B1QW",
|
||||
prompt: "Fix the login bug",
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
describe("evaluate", () => {
|
||||
test("$START → first role (fallback)", async () => {
|
||||
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
||||
expect(result).toEqual({ ok: true, value: "planner" });
|
||||
});
|
||||
|
||||
test("condition match (notApproved → developer)", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "developer" });
|
||||
});
|
||||
|
||||
test("fallback when condition does not match → $END", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: true },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "$END" });
|
||||
});
|
||||
|
||||
test("missing role in graph → error", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "unknown-role",
|
||||
output: {},
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||
}
|
||||
});
|
||||
|
||||
test("output expansion in context works with JSONata", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { needsClarification: true },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "developer" });
|
||||
});
|
||||
});
|
||||
Generated
-68
@@ -1,68 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/json-cas':
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0
|
||||
|
||||
packages:
|
||||
|
||||
'@uncaged/json-cas@0.3.0':
|
||||
resolution: {integrity: sha512-LR8Uow7cBdvH+6y9mh9Fd7zDs8fWhfhpVZVsexfdK1KKnGaR7WvukuhBj6r0FbOZ78j7jhjeEfzsUXR2cHELwQ==}
|
||||
|
||||
ajv@8.20.0:
|
||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||
|
||||
cborg@4.5.8:
|
||||
resolution: {integrity: sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==}
|
||||
hasBin: true
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
xxhash-wasm@1.1.0:
|
||||
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@uncaged/json-cas@0.3.0':
|
||||
dependencies:
|
||||
ajv: 8.20.0
|
||||
cborg: 4.5.8
|
||||
xxhash-wasm: 1.1.0
|
||||
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.2
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
cborg@4.5.8: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-uri@3.1.2: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
xxhash-wasm@1.1.0: {}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/uwf-agent-hermes",
|
||||
"version": "0.1.0",
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.5.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
@@ -21,8 +21,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^"
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"@uncaged/workflow-agent-kit": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
+74
-27
@@ -1,11 +1,16 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
|
||||
import { type AgentContext, type AgentRunResult, createAgent } from "@uncaged/uwf-agent-kit";
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentRunResult,
|
||||
buildRolePrompt,
|
||||
createAgent,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
|
||||
import {
|
||||
loadHermesSession,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesRawOutput,
|
||||
storeHermesSessionDetail,
|
||||
} from "./session-detail.js";
|
||||
|
||||
@@ -34,12 +39,12 @@ function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
const systemPrompt = roleDef?.systemPrompt ?? "";
|
||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||
const parts: string[] = [];
|
||||
if (ctx.outputFormatInstruction !== undefined && ctx.outputFormatInstruction !== "") {
|
||||
parts.push(ctx.outputFormatInstruction, "");
|
||||
}
|
||||
parts.push(systemPrompt, "", "## Task", ctx.start.prompt);
|
||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
@@ -47,17 +52,8 @@ export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
function spawnHermes(args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
prompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
];
|
||||
const child = spawn(HERMES_COMMAND, args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
@@ -89,23 +85,73 @@ function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: stri
|
||||
});
|
||||
}
|
||||
|
||||
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnHermes([
|
||||
"chat",
|
||||
"-q",
|
||||
prompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
]);
|
||||
}
|
||||
|
||||
function spawnHermesResume(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnHermes([
|
||||
"chat",
|
||||
"--resume",
|
||||
sessionId,
|
||||
"-q",
|
||||
message,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
]);
|
||||
}
|
||||
|
||||
function parseSessionId(stdout: string, stderr: string): string {
|
||||
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
if (sessionId === null) {
|
||||
throw new Error(
|
||||
"Failed to parse session_id from hermes output.\n" +
|
||||
`stderr (first 200 chars): ${stderr.slice(0, 200)}\n` +
|
||||
`stdout (first 200 chars): ${stdout.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function buildResultFromSession(sessionId: string, store: Store): Promise<AgentRunResult> {
|
||||
const session = await loadHermesSession(sessionId);
|
||||
if (session === null) {
|
||||
throw new Error(`Failed to load hermes session file for session_id: ${sessionId}`);
|
||||
}
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session);
|
||||
return { output, detailHash, sessionId };
|
||||
}
|
||||
|
||||
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const fullPrompt = buildHermesPrompt(ctx);
|
||||
const { stdout, stderr } = await spawnHermesChat(fullPrompt);
|
||||
const { store } = ctx;
|
||||
const sessionId = parseSessionId(stdout, stderr);
|
||||
return buildResultFromSession(sessionId, ctx.store);
|
||||
}
|
||||
|
||||
// --quiet mode: session_id may be on stdout or stderr
|
||||
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
if (sessionId !== null) {
|
||||
const session = await loadHermesSession(sessionId);
|
||||
if (session !== null) {
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session);
|
||||
return { output, detailHash };
|
||||
}
|
||||
}
|
||||
|
||||
const detailHash = await storeHermesRawOutput(store, stdout);
|
||||
return { output: stdout, detailHash };
|
||||
async function continueHermes(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: Store,
|
||||
): Promise<AgentRunResult> {
|
||||
const { stdout, stderr } = await spawnHermesResume(sessionId, message);
|
||||
// Resume may return a new session_id
|
||||
const newSessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
const resolvedId = newSessionId ?? sessionId;
|
||||
return buildResultFromSession(resolvedId, store);
|
||||
}
|
||||
|
||||
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
||||
@@ -113,5 +159,6 @@ export function createHermesAgent(): () => Promise<void> {
|
||||
return createAgent({
|
||||
name: "hermes",
|
||||
run: runHermes,
|
||||
continue: continueHermes,
|
||||
});
|
||||
}
|
||||
@@ -5,5 +5,5 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../uwf-protocol" }]
|
||||
"references": [{ "path": "../workflow-agent-kit" }]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["ready", "insufficient_info"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const REVIEWER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
approved: { type: "boolean" },
|
||||
},
|
||||
required: ["approved"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
describe("buildOutputFormatInstruction", () => {
|
||||
test("always includes the frontmatter example block", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("---");
|
||||
expect(result).not.toContain("status: done");
|
||||
expect(result).not.toContain("confidence:");
|
||||
expect(result).not.toContain("scope: role");
|
||||
});
|
||||
|
||||
test("always marks frontmatter as the primary deliverable", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("primary deliverable");
|
||||
});
|
||||
|
||||
test("generates planner-specific YAML example from schema", () => {
|
||||
const result = buildOutputFormatInstruction(PLANNER_SCHEMA);
|
||||
expect(result).toContain("status: ready # required | ready | insufficient_info");
|
||||
expect(result).toContain("plan: <string>");
|
||||
expect(result).not.toContain("status: done");
|
||||
expect(result).not.toContain("confidence:");
|
||||
expect(result).not.toContain("artifacts:");
|
||||
});
|
||||
|
||||
test("generates reviewer-specific YAML example from schema", () => {
|
||||
const result = buildOutputFormatInstruction(REVIEWER_SCHEMA);
|
||||
expect(result).toContain("approved: true # required | true | false");
|
||||
expect(result).not.toContain("status:");
|
||||
});
|
||||
|
||||
test("lists fields from a flat object schema with required marker", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string" },
|
||||
confidence: { type: "number" },
|
||||
},
|
||||
required: ["status"],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`status` (required)");
|
||||
expect(result).toContain("`confidence`");
|
||||
expect(result).not.toContain("`confidence` (required)");
|
||||
expect(result).toContain("status: <string> # required");
|
||||
expect(result).toContain("confidence: <number>");
|
||||
});
|
||||
|
||||
test("lists union of fields from an anyOf schema", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { alpha: { type: "string" } },
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { beta: { type: "number" } },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`alpha`");
|
||||
expect(result).toContain("`beta`");
|
||||
expect(result).toContain("alpha: <string>");
|
||||
expect(result).toContain("beta: <number>");
|
||||
});
|
||||
|
||||
test("lists union of fields from a oneOf schema", () => {
|
||||
const schema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { foo: { type: "string" } },
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { bar: { type: "boolean" } },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`foo`");
|
||||
expect(result).toContain("`bar`");
|
||||
expect(result).toContain("foo: <string>");
|
||||
expect(result).toContain("bar: true # true | false");
|
||||
});
|
||||
|
||||
test("falls back gracefully for a non-object schema with no properties", () => {
|
||||
const result = buildOutputFormatInstruction({ type: "string" });
|
||||
expect(result).toContain("schema fields will be extracted automatically");
|
||||
});
|
||||
|
||||
test("does not list a field more than once for a union with overlapping keys", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{ type: "object", properties: { shared: { type: "string" } } },
|
||||
{ type: "object", properties: { shared: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
const matches = [...result.matchAll(/`shared`/g)];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(result).toContain("shared: <string>");
|
||||
});
|
||||
|
||||
test("marks required when any union variant requires the field", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { shared: { type: "string" } },
|
||||
required: ["shared"],
|
||||
},
|
||||
{ type: "object", properties: { shared: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`shared` (required)");
|
||||
expect(result).toContain("shared: <string> # required");
|
||||
});
|
||||
|
||||
test("explicitly forbids extra frontmatter fields", () => {
|
||||
const result = buildOutputFormatInstruction(PLANNER_SCHEMA);
|
||||
expect(result).toMatch(/\b(only|exclusively)\b.*fields/i);
|
||||
expect(result).toMatch(/do not add (extra|additional|other) fields/i);
|
||||
});
|
||||
|
||||
test("forbids extra fields even for empty schema", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toMatch(/do not add (extra|additional|other) fields/i);
|
||||
});
|
||||
|
||||
test("forbids extra fields for anyOf/oneOf schemas", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{ type: "object", properties: { alpha: { type: "string" } } },
|
||||
{ type: "object", properties: { beta: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toMatch(/do not add (extra|additional|other) fields/i);
|
||||
});
|
||||
|
||||
test("includes focus reminder about role scope", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("Focus exclusively on YOUR role");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-protocol";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildRolePrompt } from "../src/build-role-prompt.js";
|
||||
|
||||
describe("buildRolePrompt", () => {
|
||||
test("all fields present", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "A coder",
|
||||
goal: "You are a senior developer.",
|
||||
capabilities: ["cursor-agent", "file-edit"],
|
||||
procedure: "Implement the feature.",
|
||||
output: "Summarize changes.",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Goal");
|
||||
expect(result).toContain("You are a senior developer.");
|
||||
expect(result).toContain("## Capabilities");
|
||||
expect(result).toContain("- cursor-agent");
|
||||
expect(result).toContain("- file-edit");
|
||||
expect(result).toContain("## Prepare");
|
||||
expect(result).toContain("uwf CLI Reference");
|
||||
expect(result).toContain("cursor-agent, file-edit");
|
||||
expect(result).toContain("## Procedure");
|
||||
expect(result).toContain("Implement the feature.");
|
||||
expect(result).toContain("## Output");
|
||||
expect(result).toContain("Summarize changes.");
|
||||
});
|
||||
|
||||
test("empty fields are omitted but Prepare is always present", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "A reviewer",
|
||||
goal: "You are a code reviewer.",
|
||||
capabilities: [],
|
||||
procedure: "Review the PR diff carefully.",
|
||||
output: "",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Goal");
|
||||
expect(result).toContain("## Prepare");
|
||||
expect(result).toContain("uwf CLI Reference");
|
||||
expect(result).toContain("## Procedure");
|
||||
expect(result).not.toContain("## Capabilities");
|
||||
expect(result).not.toContain("## Output");
|
||||
});
|
||||
|
||||
test("all empty still includes Prepare section", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "Minimal",
|
||||
goal: "",
|
||||
capabilities: [],
|
||||
procedure: "",
|
||||
output: "",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Prepare");
|
||||
expect(result).toContain("uwf CLI Reference");
|
||||
expect(result).not.toContain("## Goal");
|
||||
expect(result).not.toContain("## Capabilities");
|
||||
expect(result).not.toContain("## Procedure");
|
||||
expect(result).not.toContain("## Output");
|
||||
});
|
||||
|
||||
test("capabilities rendered as bullet list", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "Agent",
|
||||
goal: "",
|
||||
capabilities: ["search", "code", "browse"],
|
||||
procedure: "",
|
||||
output: "",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Capabilities");
|
||||
expect(result).toContain("- search");
|
||||
expect(result).toContain("- code");
|
||||
expect(result).toContain("- browse");
|
||||
});
|
||||
});
|
||||
+69
-3
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
|
||||
|
||||
@@ -30,6 +29,27 @@ const STRICT_SCHEMA = {
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Role-specific schema (reviewer) — only approved, no standard agent fields. */
|
||||
const REVIEWER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
approved: { type: "boolean" },
|
||||
},
|
||||
required: ["approved"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Role-specific schema (planner) — custom status enum + plan hash. */
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["ready", "insufficient_info"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
async function makeStoreWithSchema(schema: Record<string, unknown>) {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
@@ -68,7 +88,8 @@ describe("tryFrontmatterFastPath — happy path", () => {
|
||||
test("stored CAS node payload matches frontmatter fields", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
|
||||
const raw =
|
||||
"---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
@@ -134,3 +155,48 @@ describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Role-specific schema fields ───────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — role-specific fields", () => {
|
||||
test("extracts approved only for reviewer schema (no extra standard fields)", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
|
||||
|
||||
const raw = "---\napproved: true\n---\n\nReview passed.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload).toEqual({ approved: true });
|
||||
expect(payload.status).toBeUndefined();
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
test("extracts plan and role-specific status for planner schema", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(PLANNER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: ready\nplan: 01HASHPLANNER0001\n---\n\nSpec summary.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("ready");
|
||||
expect(payload.plan).toBe("01HASHPLANNER0001");
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns null when required role-specific field is missing", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { WorkflowConfig } from "@uncaged/uwf-protocol";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow-protocol";
|
||||
import { resolveExtractModelAlias } from "../src/extract.js";
|
||||
|
||||
function baseConfig(overrides: Partial<WorkflowConfig> = {}): WorkflowConfig {
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/uwf-agent-kit",
|
||||
"version": "0.1.0",
|
||||
"name": "@uncaged/workflow-agent-kit",
|
||||
"version": "0.5.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
@@ -18,9 +18,9 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"@uncaged/json-cas-fs": "^0.4.0",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"dotenv": "^16.6.1",
|
||||
"yaml": "^2.8.4"
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
|
||||
type SchemaProperty = {
|
||||
name: string;
|
||||
schema: JSONSchema;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract top-level property names from a JSON Schema object.
|
||||
*
|
||||
* Handles:
|
||||
* - Object schemas with a `properties` key
|
||||
* - Union schemas via `anyOf` / `oneOf` — union of all variant property names
|
||||
*
|
||||
* Returns an empty array for schemas with no inspectable property definitions.
|
||||
*/
|
||||
export function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
return extractSchemaProperties(schema).map((p) => p.name);
|
||||
}
|
||||
|
||||
function extractSchemaProperties(schema: JSONSchema): SchemaProperty[] {
|
||||
const objectSchemas = collectObjectSchemas(schema);
|
||||
if (objectSchemas.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const byName = new Map<string, SchemaProperty>();
|
||||
|
||||
for (const objectSchema of objectSchemas) {
|
||||
const requiredSet = new Set(
|
||||
Array.isArray(objectSchema.required) ? (objectSchema.required as string[]) : [],
|
||||
);
|
||||
const properties = objectSchema.properties as Record<string, JSONSchema> | null | undefined;
|
||||
if (typeof properties !== "object" || properties === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [name, propSchema] of Object.entries(properties)) {
|
||||
const required = requiredSet.has(name);
|
||||
const existing = byName.get(name);
|
||||
if (existing === undefined) {
|
||||
byName.set(name, { name, schema: propSchema, required });
|
||||
} else if (required) {
|
||||
byName.set(name, { ...existing, required: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function collectObjectSchemas(schema: JSONSchema): JSONSchema[] {
|
||||
if (typeof schema.properties === "object" && schema.properties !== null) {
|
||||
return [schema];
|
||||
}
|
||||
|
||||
const unionKey = Array.isArray(schema.anyOf)
|
||||
? "anyOf"
|
||||
: Array.isArray(schema.oneOf)
|
||||
? "oneOf"
|
||||
: null;
|
||||
|
||||
if (unionKey === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const variants = schema[unionKey] as JSONSchema[];
|
||||
const result: JSONSchema[] = [];
|
||||
for (const variant of variants) {
|
||||
result.push(...collectObjectSchemas(variant));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolvePropertySchema(prop: JSONSchema): JSONSchema {
|
||||
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
||||
return prop;
|
||||
}
|
||||
|
||||
const unionKey = Array.isArray(prop.anyOf) ? "anyOf" : Array.isArray(prop.oneOf) ? "oneOf" : null;
|
||||
|
||||
if (unionKey !== null) {
|
||||
const variants = prop[unionKey] as JSONSchema[];
|
||||
const nonNull = variants.filter((v) => v.type !== "null");
|
||||
if (nonNull.length === 1) {
|
||||
return nonNull[0];
|
||||
}
|
||||
}
|
||||
|
||||
return prop;
|
||||
}
|
||||
|
||||
function formatYamlScalar(value: unknown): string {
|
||||
if (typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function buildPropertyComment(parts: string[]): string {
|
||||
const filtered = parts.filter((p) => p.length > 0);
|
||||
return filtered.length > 0 ? ` # ${filtered.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function buildPropertyExampleLine(prop: SchemaProperty): string {
|
||||
const resolved = resolvePropertySchema(prop.schema);
|
||||
const commentParts: string[] = [];
|
||||
if (prop.required) {
|
||||
commentParts.push("required");
|
||||
}
|
||||
|
||||
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
|
||||
const enumValues = resolved.enum.map((v) => String(v));
|
||||
commentParts.push(...enumValues);
|
||||
const first = resolved.enum[0];
|
||||
return `${prop.name}: ${formatYamlScalar(first)}${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "boolean") {
|
||||
commentParts.push("true", "false");
|
||||
return `${prop.name}: true${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "string") {
|
||||
return `${prop.name}: <string>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "number" || resolved.type === "integer") {
|
||||
return `${prop.name}: <number>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "array") {
|
||||
return `${prop.name}:\n - <item>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "object") {
|
||||
return `${prop.name}: <object>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
return `${prop.name}: <value>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
function buildYamlExampleBlock(properties: SchemaProperty[]): string {
|
||||
if (properties.length === 0) {
|
||||
return "---\n\n... your markdown work here ...";
|
||||
}
|
||||
|
||||
const lines = properties.map((p) => buildPropertyExampleLine(p));
|
||||
return `---\n${lines.join("\n")}\n---\n\n... your markdown work here ...`;
|
||||
}
|
||||
|
||||
function buildFieldList(properties: SchemaProperty[]): string {
|
||||
if (properties.length === 0) {
|
||||
return " (schema fields will be extracted automatically)";
|
||||
}
|
||||
|
||||
return properties
|
||||
.map((p) => {
|
||||
const suffix = p.required ? " (required)" : "";
|
||||
return ` - \`${p.name}\`${suffix}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a concise output format instruction block for an agent role.
|
||||
*
|
||||
* The instruction describes the expected frontmatter markdown format and lists
|
||||
* the meta fields derived from the JSON Schema. It is prepended to the agent's
|
||||
* system prompt so the deliverable format is the first thing the agent sees.
|
||||
*/
|
||||
export function buildOutputFormatInstruction(schema: JSONSchema): string {
|
||||
const properties = extractSchemaProperties(schema);
|
||||
const yamlExample = buildYamlExampleBlock(properties);
|
||||
const fieldList = buildFieldList(properties);
|
||||
|
||||
return `## Deliverable Format
|
||||
|
||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
||||
|
||||
\`\`\`
|
||||
${yamlExample}
|
||||
\`\`\`
|
||||
|
||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||
Your meta output must satisfy these fields:
|
||||
|
||||
${fieldList}
|
||||
|
||||
Output ONLY the fields listed above. Do not add extra fields that are not specified in the schema.
|
||||
|
||||
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-protocol";
|
||||
import { generateCliReference } from "@uncaged/workflow-util";
|
||||
|
||||
/**
|
||||
* Build the role prompt from a RoleDefinition.
|
||||
*
|
||||
* Assembles structured sections: Goal, Capabilities, Prepare, Procedure, Output.
|
||||
* Empty strings and empty arrays are omitted from the output.
|
||||
*
|
||||
* The Prepare section always inlines the uwf CLI reference so the agent has
|
||||
* workflow knowledge without needing to run an external command. The capabilities
|
||||
* array is rendered as keyword hints for implicit skill loading.
|
||||
*/
|
||||
export function buildRolePrompt(role: RoleDefinition): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (role.goal !== "") {
|
||||
sections.push(`## Goal\n\n${role.goal}`);
|
||||
}
|
||||
|
||||
if (role.capabilities.length > 0) {
|
||||
const list = role.capabilities.map((c) => `- ${c}`).join("\n");
|
||||
sections.push(`## Capabilities\n\n${list}`);
|
||||
}
|
||||
|
||||
const prepareLines: string[] = [generateCliReference()];
|
||||
if (role.capabilities.length > 0) {
|
||||
const keywords = role.capabilities.join(", ");
|
||||
prepareLines.push(
|
||||
`You have the following capabilities: ${keywords}. Load relevant skills matching these keywords before starting work.`,
|
||||
);
|
||||
}
|
||||
sections.push(`## Prepare\n\n${prepareLines.join("\n\n")}`);
|
||||
|
||||
if (role.procedure !== "") {
|
||||
sections.push(`## Procedure\n\n${role.procedure}`);
|
||||
}
|
||||
|
||||
if (role.output !== "") {
|
||||
sections.push(`## Output\n\n${role.output}`);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import type {
|
||||
StepContext,
|
||||
StepNodePayload,
|
||||
ThreadId,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentContext } from "./types.js";
|
||||
|
||||
type ChainState = {
|
||||
@@ -21,11 +21,7 @@ function fail(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function walkChain(
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
headHash: CasRef,
|
||||
): ChainState {
|
||||
function walkChain(store: Store, schemas: AgentStore["schemas"], headHash: CasRef): ChainState {
|
||||
const headNode = store.get(headHash);
|
||||
if (headNode === null) {
|
||||
fail(`CAS node not found: ${headHash}`);
|
||||
@@ -78,10 +74,7 @@ function walkChain(
|
||||
};
|
||||
}
|
||||
|
||||
function expandOutput(
|
||||
store: Store,
|
||||
outputRef: CasRef,
|
||||
): unknown {
|
||||
function expandOutput(store: Store, outputRef: CasRef): unknown {
|
||||
const node = store.get(outputRef);
|
||||
if (node === null) {
|
||||
return {};
|
||||
@@ -106,11 +99,7 @@ async function buildHistory(
|
||||
return history;
|
||||
}
|
||||
|
||||
async function loadWorkflow(
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
workflowRef: CasRef,
|
||||
) {
|
||||
async function loadWorkflow(store: Store, schemas: AgentStore["schemas"], workflowRef: CasRef) {
|
||||
const node = store.get(workflowRef);
|
||||
if (node === null) {
|
||||
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
|
||||
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/uwf-protocol";
|
||||
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/workflow-protocol";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { createAgentStore, getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
type AgentFrontmatter,
|
||||
createLogger,
|
||||
parseFrontmatterMarkdown,
|
||||
validateFrontmatter,
|
||||
} from "@uncaged/workflow-util";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import { extractSchemaFields } from "./build-output-format-instruction.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
const STANDARD_KEYS = ["status", "next", "confidence", "artifacts", "scope"] as const;
|
||||
|
||||
type StandardKey = (typeof STANDARD_KEYS)[number];
|
||||
|
||||
export type FrontmatterFastPathResult = {
|
||||
body: string;
|
||||
outputHash: CasRef;
|
||||
};
|
||||
|
||||
function extractYamlBlock(raw: string): string | null {
|
||||
const fence = "---";
|
||||
if (!raw.startsWith(fence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = raw.slice(fence.length);
|
||||
if (rest.length > 0 && rest[0] !== "\n" && rest[0] !== "\r") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const afterOpen = rest.startsWith("\n") ? rest.slice(1) : rest;
|
||||
const closeIndex = afterOpen.indexOf(`\n${fence}`);
|
||||
if (closeIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return afterOpen.slice(0, closeIndex);
|
||||
}
|
||||
|
||||
function parseRawFrontmatterFields(raw: string): Record<string, unknown> {
|
||||
const yamlText = extractYamlBlock(raw);
|
||||
if (yamlText === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseYaml(yamlText);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCandidate(frontmatter: AgentFrontmatter): Record<string, unknown> {
|
||||
return {
|
||||
status: frontmatter.status,
|
||||
next: frontmatter.next,
|
||||
confidence: frontmatter.confidence,
|
||||
artifacts: [...frontmatter.artifacts],
|
||||
scope: frontmatter.scope,
|
||||
};
|
||||
}
|
||||
|
||||
function pickStandardField(frontmatter: AgentFrontmatter, key: StandardKey): unknown {
|
||||
switch (key) {
|
||||
case "status":
|
||||
return frontmatter.status;
|
||||
case "next":
|
||||
return frontmatter.next;
|
||||
case "confidence":
|
||||
return frontmatter.confidence;
|
||||
case "artifacts":
|
||||
return [...frontmatter.artifacts];
|
||||
case "scope":
|
||||
return frontmatter.scope;
|
||||
}
|
||||
}
|
||||
|
||||
function isStandardKey(key: string): key is StandardKey {
|
||||
return (STANDARD_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function pickFieldValue(
|
||||
field: string,
|
||||
frontmatter: AgentFrontmatter,
|
||||
rawFields: Record<string, unknown>,
|
||||
): unknown | undefined {
|
||||
if (!isStandardKey(field)) {
|
||||
return Object.hasOwn(rawFields, field) ? rawFields[field] : undefined;
|
||||
}
|
||||
|
||||
const coerced = pickStandardField(frontmatter, field);
|
||||
if (field === "artifacts" || field === "scope") {
|
||||
return coerced;
|
||||
}
|
||||
if (coerced !== null) {
|
||||
return coerced;
|
||||
}
|
||||
return Object.hasOwn(rawFields, field) ? rawFields[field] : coerced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CAS candidate object from schema property keys and parsed frontmatter.
|
||||
*
|
||||
* When the schema has no inspectable properties, falls back to the five standard
|
||||
* agent frontmatter fields for backward compatibility.
|
||||
*/
|
||||
function buildCandidate(
|
||||
frontmatter: AgentFrontmatter,
|
||||
rawFields: Record<string, unknown>,
|
||||
schemaFields: string[],
|
||||
): Record<string, unknown> {
|
||||
if (schemaFields.length === 0) {
|
||||
return defaultCandidate(frontmatter);
|
||||
}
|
||||
|
||||
const candidate: Record<string, unknown> = {};
|
||||
|
||||
for (const field of schemaFields) {
|
||||
const value = pickFieldValue(field, frontmatter, rawFields);
|
||||
if (value !== undefined) {
|
||||
candidate[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to satisfy `outputSchema` from frontmatter fields alone.
|
||||
*
|
||||
* Returns a result containing the stored CAS hash and stripped body on success,
|
||||
* or `null` when frontmatter is absent, invalid, or does not satisfy the schema.
|
||||
* Never throws.
|
||||
*
|
||||
* The candidate object is put into the real CAS store (idempotent content-addressed
|
||||
* write) and validated against the output schema. If validation fails the node
|
||||
* is orphaned — it will be GC'd on the next collection pass.
|
||||
*/
|
||||
export async function tryFrontmatterFastPath(
|
||||
raw: string,
|
||||
outputSchema: CasRef,
|
||||
store: Store,
|
||||
): Promise<FrontmatterFastPathResult | null> {
|
||||
const { frontmatter, body } = parseFrontmatterMarkdown(raw);
|
||||
|
||||
if (frontmatter === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationErrors = validateFrontmatter(frontmatter);
|
||||
if (validationErrors.length > 0) {
|
||||
log(
|
||||
"9GNPS4WY",
|
||||
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const schema = getSchema(store, outputSchema);
|
||||
if (schema === null) {
|
||||
log("8FHMR2QX", `output schema not found in CAS: ${outputSchema}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const schemaFields = extractSchemaFields(schema);
|
||||
const rawFields = parseRawFrontmatterFields(raw);
|
||||
const candidate = buildCandidate(frontmatter, rawFields, schemaFields);
|
||||
|
||||
let outputHash: CasRef;
|
||||
let node: ReturnType<Store["get"]>;
|
||||
|
||||
try {
|
||||
outputHash = await store.put(outputSchema, candidate);
|
||||
node = store.get(outputHash);
|
||||
} catch {
|
||||
log("2KMQT7NR", "failed to store frontmatter candidate in CAS");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node === null || !validate(store, node)) {
|
||||
log("2KMQT7NR", "stored frontmatter candidate failed schema validation");
|
||||
return null;
|
||||
}
|
||||
|
||||
return { body, outputHash };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
export { buildRolePrompt } from "./build-role-prompt.js";
|
||||
export type { BuildContextMeta } from "./context.js";
|
||||
export { buildContext, buildContextWithMeta } from "./context.js";
|
||||
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
|
||||
@@ -6,9 +8,14 @@ export {
|
||||
resolveExtractModelAlias,
|
||||
resolveModel,
|
||||
} from "./extract.js";
|
||||
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
|
||||
export type {
|
||||
AgentContext,
|
||||
AgentContinueFn,
|
||||
AgentOptions,
|
||||
AgentRunFn,
|
||||
AgentRunResult,
|
||||
} from "./types.js";
|
||||
@@ -1,14 +1,14 @@
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol";
|
||||
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
|
||||
import { buildContextWithMeta } from "./context.js";
|
||||
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
import { extract } from "./extract.js";
|
||||
import { buildContextWithMeta } from "./context.js";
|
||||
import { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
|
||||
import { getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentOptions } from "./types.js";
|
||||
|
||||
const MAX_FRONTMATTER_RETRIES = 2;
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
@@ -67,31 +67,16 @@ async function writeStepNode(options: {
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> {
|
||||
return runWithMessage("agent run failed", () => options.run(ctx));
|
||||
}
|
||||
|
||||
async function extractOutput(
|
||||
async function tryExtractOutput(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
storageRoot: string,
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
|
||||
): Promise<CasRef> {
|
||||
const fastPath = await runWithMessage("frontmatter fast path", () =>
|
||||
tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store),
|
||||
).catch(() => null);
|
||||
|
||||
): Promise<CasRef | null> {
|
||||
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
|
||||
if (fastPath !== null) {
|
||||
return fastPath.outputHash;
|
||||
}
|
||||
|
||||
const config = await runWithMessage("failed to load config", () =>
|
||||
loadWorkflowConfig(storageRoot),
|
||||
);
|
||||
const extracted = await runWithMessage("extract failed", () =>
|
||||
extract(rawOutput, outputSchema, config),
|
||||
);
|
||||
return extracted.hash;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function persistStep(options: {
|
||||
@@ -113,11 +98,6 @@ async function persistStep(options: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent CLI entrypoint.
|
||||
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
|
||||
* writes StepNode to CAS, and prints the new node hash to stdout.
|
||||
*/
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
@@ -131,13 +111,36 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
fail(`unknown role: ${role}`);
|
||||
}
|
||||
|
||||
const outputSchema = getSchema(ctx.meta.store, roleDef.outputSchema);
|
||||
if (outputSchema !== null) {
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(outputSchema);
|
||||
const frontmatterSchema = getSchema(ctx.meta.store, roleDef.frontmatter);
|
||||
if (frontmatterSchema !== null) {
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
|
||||
}
|
||||
|
||||
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
|
||||
|
||||
// Try to extract frontmatter; retry via continue if it fails
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
|
||||
const correctionMessage =
|
||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||
"Please output ONLY the corrected frontmatter block followed by your work.";
|
||||
|
||||
agentResult = await runWithMessage("agent continue failed", () =>
|
||||
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
|
||||
);
|
||||
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
}
|
||||
|
||||
if (outputHash === null) {
|
||||
fail(
|
||||
"Agent output does not contain valid YAML frontmatter matching the role schema " +
|
||||
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
|
||||
`Raw output (first 500 chars): ${agentResult.output.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const agentResult = await runAgent(options, ctx);
|
||||
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot, ctx);
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/uwf-protocol";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type UwfAgentSchemaHashes = {
|
||||
workflow: Hash;
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
ThreadsIndex,
|
||||
WorkflowConfig,
|
||||
WorkflowName,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import { registerAgentSchemas } from "./schemas.js";
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
@@ -7,7 +7,7 @@ export type AgentContext = ModeratorContext & {
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
/**
|
||||
* Prepend to the role's systemPrompt when building the agent prompt.
|
||||
* Prepend to the role's prompt when building the agent prompt.
|
||||
* Contains the frontmatter deliverable format instruction derived from the
|
||||
* role's output schema. Populated by `createAgent` at run time.
|
||||
*/
|
||||
@@ -17,11 +17,19 @@ export type AgentContext = ModeratorContext & {
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
@@ -5,5 +5,5 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../uwf-agent-kit" }]
|
||||
"references": [{ "path": "../workflow-protocol" }]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
|
||||
# Workflow UI — 开发上下文文档
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
workflow-dashboard 是一个 Web 图形编辑器,用于可视化展示和编辑工作流(Workflow)的结构。
|
||||
|
||||
**核心场景**:
|
||||
- 用户本地执行 `uwf connect` 命令,通过 WebSocket 连接到此 Web 服务
|
||||
- CLI 将本地 YAML 工作流文件发送到 server
|
||||
- Server 解析后,提供图形化界面展示工作流的节点拓扑,允许用户进行逻辑编排和节点编辑
|
||||
- 编辑完成后,数据可回传给 CLI 或持久化
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
|---|------|------|
|
||||
| 图编辑器 | @xyflow/react v12 | 节点/边渲染、拖拽、连线(strict 连接模式) |
|
||||
| 前端框架 | React 19 | UI 组件 |
|
||||
| 路由 | react-router v7 | Hash 模式路由 |
|
||||
| 状态管理 | 自研 (context.tsx) | 基于 useSyncExternalStore + Immer |
|
||||
| 样式 | Tailwind CSS v4 | 原子化 CSS |
|
||||
| 图标 | lucide-react | 图标库 |
|
||||
| 构建工具 | Vite 8 | Dev server + 打包 |
|
||||
| 后端框架 | Elysia | 轻量 REST API(当前为 stub) |
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
```
|
||||
workflow-dashboard/
|
||||
├── server.ts # Vite dev server 入口 (port 3000)
|
||||
├── vite.config.ts # Vite 配置(react + tailwind + elysia 插件 + @ 别名)
|
||||
├── vite-dev.ts # 自定义 Vite 插件
|
||||
├── components.json # shadcn 配置
|
||||
├── server/
|
||||
│ ├── api.ts # Elysia REST API (health + workflow CRUD)
|
||||
│ └── workflow.ts # Workflow 文件读写 + 格式转换
|
||||
├── tmp/workflow/ # Workflow YAML 存储目录(开发阶段)
|
||||
├── src/
|
||||
│ ├── main.tsx # React DOM 入口
|
||||
│ ├── router.tsx # React Router 配置
|
||||
│ ├── app.tsx # 根布局组件
|
||||
│ ├── lib/utils.ts # Tailwind cn() 工具
|
||||
│ ├── components/ui/ # shadcn 组件(button, card, dialog, input, textarea)
|
||||
│ ├── pages/
|
||||
│ │ ├── home.tsx # Home 列表页(workflow 管理)
|
||||
│ │ └── detail.tsx # Workflow 详情/编辑页
|
||||
│ └── editor/ # ★ 核心编辑器
|
||||
│ ├── flow.tsx # FlowEditor 组件 + 公开 API 导出
|
||||
│ ├── type.ts # 内部类型定义
|
||||
│ ├── context.tsx # 自研状态管理框架
|
||||
│ ├── injection.ts # DI 容器(FlowModel / Injection)
|
||||
│ ├── model/ # 状态模型层
|
||||
│ ├── nodes/ # 节点渲染组件
|
||||
│ ├── edges/ # 边渲染组件
|
||||
│ ├── panel/ # UI 面板(工具栏、添加/编辑面板)
|
||||
│ ├── trans/ # 数据转换层(内外格式互转)
|
||||
│ ├── layout/ # 自动布局算法
|
||||
│ └── utils/ # 工具函数
|
||||
```
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 外部格式 — WorkFlowSteps(与 CLI 交换的数据)
|
||||
|
||||
`WorkFlowSteps` 是 `WorkFlowStep[]`,每个 step 描述一个角色节点及其转移关系:
|
||||
|
||||
```typescript
|
||||
type WorkFlowRole = {
|
||||
name: string; // 角色名称(唯一标识)
|
||||
description: string; // 角色描述
|
||||
identity: string; // 身份定义(system prompt)
|
||||
prepare: string; // 执行前准备指令
|
||||
execute: string; // 核心执行指令
|
||||
report: string; // 输出格式指令
|
||||
};
|
||||
|
||||
type WorkFlowTransition = {
|
||||
target: string; // 目标角色名 或 'END'
|
||||
condition: string | null; // 条件表达式,null 为 else(无条件兜底)
|
||||
};
|
||||
|
||||
type WorkFlowStep = {
|
||||
role: WorkFlowRole;
|
||||
transitions: WorkFlowTransition[];
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 内部格式 — ReactFlow Nodes & Edges
|
||||
|
||||
编辑器内部使用 ReactFlow 的 Node/Edge 模型:
|
||||
|
||||
**节点类型**:
|
||||
- `start` → 起始节点(右侧 1 个 source handle)
|
||||
- `end` → 结束节点(左侧 1 个 target handle)
|
||||
- `role` → 角色节点(6 个 handle,见下方)
|
||||
|
||||
**Role 节点 Handle 布局**:
|
||||
|
||||
| 位置 | 类型 | ID | 颜色 |
|
||||
|------|------|----|------|
|
||||
| 左侧 | target (in) | `input` | 蓝色 |
|
||||
| 上方 30% | target (in) | `input-top` | 蓝色 |
|
||||
| 下方 30% | target (in) | `input-bottom` | 蓝色 |
|
||||
| 右侧 | source (out) | `output` | 绿色 |
|
||||
| 上方 70% | source (out) | `output-top` | 绿色 |
|
||||
| 下方 70% | source (out) | `output-bottom` | 绿色 |
|
||||
|
||||
- target handle 设置了 `isConnectableStart`,可以从 in 拖向 out 发起连线(`onConnect` 自动纠正方向)
|
||||
- source handle 设置了 `isConnectableEnd`
|
||||
|
||||
**RoleNodeData** 对齐上游 `RoleDefinition`:
|
||||
```typescript
|
||||
type RoleNodeData = {
|
||||
name: string;
|
||||
description: string;
|
||||
identity: string;
|
||||
prepare: string;
|
||||
execute: string;
|
||||
report: string;
|
||||
};
|
||||
```
|
||||
|
||||
**边类型**:
|
||||
- `default`(GradientEdge)→ 渐变色边(绿→蓝),节点仅有一条出边时使用
|
||||
- `conditional`(ConditionalEdge)→ 带条件标签的渐变色边,节点有多条出边时使用
|
||||
|
||||
**边渲染特性**:
|
||||
- 渐变色:SVG linearGradient,从 source 端绿色(#10b981)到 target 端蓝色(#3b82f6)
|
||||
- 选中时:变为琥珀色(#f59e0b)单色,方便识别
|
||||
- 缺少条件时:红色(#ff5252)
|
||||
- 交互区域:20px 宽透明路径用于点击
|
||||
|
||||
### 4.3 Else 分支机制
|
||||
|
||||
当一个节点有多条 conditional 出边时:
|
||||
- **edges 数组中排第一个的 conditional 边自动成为 else**(兜底分支)
|
||||
- else 边显示灰色 `else` badge(不可点击,无需设置条件)
|
||||
- 其余边显示 `if` badge(需要设置条件,可点击编辑)
|
||||
- 只有一条 conditional 出边时不显示 else 标签
|
||||
- else 边在有 if 兄弟存在时不能被删除(`onBeforeDelete` 保护)
|
||||
- 序列化时 else 边输出 `condition: null`
|
||||
- 反序列化时 `condition: null` 的 transition 排序到第一个
|
||||
|
||||
### 4.4 条件边自动升级与降级
|
||||
|
||||
- **升级**:当用户从某节点拖出第二条边时,`edgesModel.onConnect` 自动将该节点所有出边升级为 `conditional` 类型。
|
||||
- **降级**:当删除 conditional 边后,若该 source 仅剩一条 conditional 出边,`handlers.onDelete` 自动将其降级回 `default` 类型。
|
||||
|
||||
### 4.5 连线约束
|
||||
|
||||
`onConnect` 中的校验逻辑:
|
||||
1. 禁止自连(source === target)
|
||||
2. 禁止同一对节点之间的重复边(source+target 去重)
|
||||
3. 方向归一化:从 input handle 拖到 output handle 时自动反转 source/target
|
||||
4. Handle 类型校验:source 端必须是 output handle,target 端必须是 input handle
|
||||
|
||||
### 4.6 数据转换层(trans/)
|
||||
|
||||
```
|
||||
WorkFlowSteps ──transIn()──→ { nodes, edges } ──transOut()──→ WorkFlowSteps
|
||||
(反序列化) (序列化)
|
||||
```
|
||||
|
||||
- `transIn(steps)`: 外部步骤列表 → ReactFlow 节点和边
|
||||
- `transOut(nodes, edges)`: ReactFlow 节点和边 → 外部步骤列表
|
||||
- `validate(nodes, edges)`: 校验图结构合法性
|
||||
|
||||
三个函数都是**纯函数**。
|
||||
|
||||
### 4.7 验证规则
|
||||
|
||||
1. start 恰好 1 个,输出恰好 1 条
|
||||
2. end 恰好 1 个,输入 ≥1 条,输出 0 条
|
||||
3. role 节点:输入 ≥1、输出 ≥1
|
||||
4. 多输出时:第一条 conditional 边为 else(跳过 condition 检查),其余必须有非空 condition
|
||||
5. role 节点总数 ≥2
|
||||
6. 无孤立节点(正向 BFS 从 start 可达 + 反向 BFS 从 end 可达)
|
||||
|
||||
## 5. 架构分层
|
||||
|
||||
### 5.1 状态管理框架(context.tsx)
|
||||
|
||||
自研的轻量响应式系统,核心概念:
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| `generate<T>()` | 创建响应式 store(get/set/use/listen) |
|
||||
| `SubModel<T, A>` | 状态切片模板(name + make() + create()) |
|
||||
| `Model` | 事务管理器 + undo/redo 栈 |
|
||||
| `define.model()` | 定义有状态有 actions 的模型 |
|
||||
| `define.view()` | 定义只读视图模型 |
|
||||
| `define.memoize()` | 定义缓存计算模型 |
|
||||
| `define.compute()` | 定义响应式依赖计算(自动追踪) |
|
||||
|
||||
使用 `useSyncExternalStore` 桥接 React 渲染。
|
||||
|
||||
### 5.2 模型层(model/)
|
||||
|
||||
| 模型 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| `nodesModel` | nodes.ts | 节点数组状态 + CRUD 操作 |
|
||||
| `edgesModel` | edges.ts | 边数组状态 + 连线 + conditional 自动升级 + 连线约束 |
|
||||
| `addNodeViewModel` | add-node-view.ts | 添加节点面板的 UI 状态 |
|
||||
| `editNodeViewModel` | edit-node-view.ts | 编辑节点面板的 UI 状态 |
|
||||
| `injection` | inject.ts | DI 实例视图模型 |
|
||||
| `handlers` | handlers.ts | 事件处理器集合(拖拽、连线、删除保护、快捷键、布局、加载/保存) |
|
||||
|
||||
### 5.3 DI 容器(injection.ts)
|
||||
|
||||
```
|
||||
FlowModel(公开 API) Injection(内部实现)
|
||||
├─ load(steps) ──emit──→ emit('load', steps) → handlers.loadSteps()
|
||||
├─ on('save', cb) emit('save', steps) ← handlers.saveData()
|
||||
└─ 持有 Injection 实例
|
||||
```
|
||||
|
||||
- `FlowModel` 是外部消费者唯一接触的类,提供 `load()` 和 `on('save')` 接口
|
||||
- 构造函数接受可选的 `inital_steps` 参数,用于加载默认工作流
|
||||
- `Injection` 是内部事件总线,解耦 server 通信与 UI 状态
|
||||
|
||||
### 5.4 事务与 Undo/Redo
|
||||
|
||||
Model 提供事务机制:
|
||||
- `startTransaction()` 快照当前状态
|
||||
- `endTransaction()` 将快照推入 undo 栈
|
||||
- Ctrl+Z / Ctrl+Y 触发撤销/重做
|
||||
- 拖拽、添加节点、删除等操作自动包裹在事务中
|
||||
|
||||
## 6. 节点体系
|
||||
|
||||
### 6.1 渲染组件
|
||||
|
||||
```
|
||||
ReactFlow
|
||||
├─ nodeTypes: { start: NodeStart, end: NodeEnd, role: NodeRole }
|
||||
└─ edgeTypes: { default: GradientEdge, conditional: ConditionalEdge }
|
||||
```
|
||||
|
||||
`NodeRole` 显示角色名(data.name),使用 teal 色系图标和标签。Handle 分蓝色(in)和绿色(out)两种颜色。
|
||||
|
||||
### 6.2 节点编辑
|
||||
|
||||
角色节点的编辑器直接内联在 AddNodePanel 和 EditNodePanel 中,可编辑字段:
|
||||
- name(必填)
|
||||
- description、identity、prepare、execute、report(textarea)
|
||||
|
||||
## 7. UI 面板
|
||||
|
||||
| 面板 | 位置 | 内容 |
|
||||
|------|------|------|
|
||||
| Toolbar | 顶部居中 | Undo/Redo、添加角色、自动布局、保存 |
|
||||
| AddNodePanel | 右下角 | 角色节点创建表单(name + 6 字段 → 确认) |
|
||||
| EditNodePanel | 右下角 | 角色节点编辑表单(预填当前数据 → 确认) |
|
||||
|
||||
AddNodePanel 和 EditNodePanel 互斥显示,点击外部自动关闭。
|
||||
|
||||
## 8. 自动布局(layout/)
|
||||
|
||||
`LayoutLR(nodes, edges)` 算法:
|
||||
1. 拓扑排序分层(BFS,start → layer 0,end → max+1)
|
||||
2. 按层分组
|
||||
3. 计算 X/Y 坐标(水平间距 80px,垂直间距 40px)
|
||||
4. 无变化时返回原数组(避免无效重渲染)
|
||||
|
||||
## 9. 核心数据流
|
||||
|
||||
### 加载工作流
|
||||
|
||||
```
|
||||
FlowModel.load(steps) / FlowModel(initialSteps)
|
||||
→ Injection.emit('load', steps)
|
||||
→ handlers.loadSteps()
|
||||
→ transIn(steps) → { nodes, edges }
|
||||
(condition: null 的 transition 排序到第一个,成为 else)
|
||||
→ nodesModel.set(nodes)
|
||||
→ edgesModel.set(edges)
|
||||
→ autoLayoutLR()
|
||||
→ model.reset()(清空 undo/redo)
|
||||
```
|
||||
|
||||
### 保存工作流
|
||||
|
||||
```
|
||||
用户点击 Save
|
||||
→ handlers.saveData()
|
||||
→ validate(nodes, edges)
|
||||
→ 校验失败 → Toast 提示错误
|
||||
→ 校验通过 → transOut(nodes, edges) → WorkFlowSteps
|
||||
(第一条 conditional 边序列化为 condition: null)
|
||||
→ Injection.emit('save', steps)
|
||||
→ FlowModel.emit('save', steps)
|
||||
→ 外部消费者(server/CLI)接收
|
||||
```
|
||||
|
||||
### 连线与条件边升级
|
||||
|
||||
```
|
||||
用户拖线连接两个节点
|
||||
→ edgesModel.onConnect(params)
|
||||
→ normalizeConnection(方向纠正)
|
||||
→ 校验(自连、重复、handle 类型)
|
||||
→ 检查 source 已有出边数量
|
||||
→ 已有出边 → 新边 + 已有边全部升级为 conditional
|
||||
→ 首条出边 → 创建普通边
|
||||
```
|
||||
|
||||
### 删除保护
|
||||
|
||||
```
|
||||
用户选中节点/边按 Delete
|
||||
→ handlers.onBeforeDelete({ nodes, edges })
|
||||
→ start/end 节点 → 阻止
|
||||
→ else 边(有 if 兄弟时)→ 阻止
|
||||
→ 其他 → 允许
|
||||
```
|
||||
|
||||
## 10. 上游数据模型参考
|
||||
|
||||
workflow-dashboard 消费的 YAML 工作流最终映射自 `WorkflowPayload`(定义在 workflow-protocol):
|
||||
|
||||
```typescript
|
||||
type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>; // 角色定义(4 段式:identity/prepare/execute/report)
|
||||
conditions: Record<string, ConditionDefinition>; // JSONata 条件表达式
|
||||
graph: Record<string, Transition[]>; // 角色间的转移图
|
||||
};
|
||||
```
|
||||
|
||||
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Transition`。外部(CLI/server)负责 `WorkflowPayload` ↔ `WorkFlowSteps` 的转换。
|
||||
|
||||
## 11. 当前状态与待完善项
|
||||
|
||||
- **WebSocket 集成**: 尚未实现,CLI connect 的 WebSocket 通信待开发
|
||||
- **验证**: 图结构校验 + 可达性检测 + else 分支规则已实现
|
||||
- **只读模式**: Detail 页面有"编辑/预览"切换按钮,但编辑器尚未实现真正的只读模式(禁止交互)
|
||||
|
||||
## 12. 业务系统
|
||||
|
||||
### 12.1 路由
|
||||
|
||||
| 路由 | 页面 | 文件 |
|
||||
|------|------|------|
|
||||
| `/` | Home — Workflow 列表 | `src/pages/home.tsx` |
|
||||
| `/workflow/:name` | Detail — 预览/编辑 | `src/pages/detail.tsx` |
|
||||
|
||||
### 12.2 后端 API
|
||||
|
||||
Elysia REST API(`server/api.ts`),通过 Vite 插件(`vite-dev.ts`)集成到 dev server。
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/workflows` | 列出所有 workflow(name + description) |
|
||||
| GET | `/api/workflows/:name` | 获取单个 workflow(返回 WorkFlowSteps JSON) |
|
||||
| POST | `/api/workflows` | 新建 workflow(body: `{name, description}`) |
|
||||
| PUT | `/api/workflows/:name` | 保存 workflow(body: WorkFlowSteps JSON) |
|
||||
| DELETE | `/api/workflows/:name` | 删除 workflow |
|
||||
|
||||
### 12.3 数据存储
|
||||
|
||||
- 存储目录:`tmp/workflow/`,文件名 `{name}.yaml`
|
||||
- 存储格式:WorkflowPayload YAML(与上游 workflow-protocol 一致)
|
||||
- Server 端负责 WorkflowPayload ↔ WorkFlowSteps 转换(`server/workflow.ts`)
|
||||
|
||||
字段映射:
|
||||
| WorkFlowRole | RoleDefinition |
|
||||
|--------------|---------------|
|
||||
| name | roles map key |
|
||||
| description | description |
|
||||
| identity | goal |
|
||||
| prepare | capabilities (join/split by `\n`) |
|
||||
| execute | procedure |
|
||||
| report | output |
|
||||
|
||||
条件映射:WorkFlowTransition.condition 存储表达式字符串,保存时提取为 named conditions map。
|
||||
|
||||
### 12.4 shadcn/ui
|
||||
|
||||
已初始化 shadcn(`components.json`),使用 `@` 路径别名。已安装组件:
|
||||
- button、card、dialog、input、textarea
|
||||
- 组件位于 `src/components/ui/`
|
||||
|
||||
### 12.5 目录结构更新
|
||||
|
||||
```
|
||||
workflow-dashboard/
|
||||
├── server/
|
||||
│ ├── api.ts # Elysia REST API(health + workflow CRUD)
|
||||
│ └── workflow.ts # Workflow 文件读写 + 格式转换
|
||||
├── src/
|
||||
│ ├── components/ui/ # shadcn 组件
|
||||
│ ├── pages/
|
||||
│ │ ├── home.tsx # Home 列表页
|
||||
│ │ └── detail.tsx # Workflow 详情/编辑页
|
||||
│ └── ...
|
||||
├── tmp/workflow/ # Workflow YAML 存储目录(开发阶段)
|
||||
└── components.json # shadcn 配置
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow UI</title>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-dashboard",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun server.ts",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"elysia": "^1.4.28",
|
||||
"immer": "^11.1.8",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router": "^7.15.1",
|
||||
"shadcn": "^4.8.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/bun": "^1.2.14",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createServer } from "vite";
|
||||
|
||||
const PORT = 3000;
|
||||
|
||||
const server = await createServer({
|
||||
server: { port: PORT },
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
|
||||
// biome-ignore lint/nursery/noConsole: CLI user-facing output
|
||||
console.log(`Workflow UI running at http://localhost:${PORT}`);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import type { WorkFlowSteps } from "../shared/types.ts";
|
||||
import {
|
||||
listWorkflows,
|
||||
getWorkflow,
|
||||
createWorkflow,
|
||||
saveWorkflow,
|
||||
deleteWorkflow,
|
||||
} from "./workflow.ts";
|
||||
|
||||
export function createApi() {
|
||||
return new Elysia({ prefix: "/api" })
|
||||
.get("/health", () => ({ status: "ok" }))
|
||||
.get("/workflows", () => listWorkflows())
|
||||
.get("/workflows/:name", async ({ params }) => {
|
||||
try {
|
||||
const steps = await getWorkflow(params.name);
|
||||
return steps;
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
})
|
||||
.post(
|
||||
"/workflows",
|
||||
async ({ body }) => {
|
||||
await createWorkflow(body.name, body.description);
|
||||
return { ok: true };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
description: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/workflows/:name",
|
||||
async ({ params, body }) => {
|
||||
const steps: WorkFlowSteps = typeof body === "string" ? JSON.parse(body) : body;
|
||||
await saveWorkflow(params.name, steps);
|
||||
return { ok: true };
|
||||
},
|
||||
{
|
||||
body: t.Array(
|
||||
t.Object({
|
||||
role: t.Object({
|
||||
name: t.String(),
|
||||
description: t.String(),
|
||||
identity: t.String(),
|
||||
prepare: t.String(),
|
||||
execute: t.String(),
|
||||
report: t.String(),
|
||||
}),
|
||||
transitions: t.Array(
|
||||
t.Object({
|
||||
target: t.String(),
|
||||
condition: t.Union([t.String(), t.Null()]),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
.delete("/workflows/:name", async ({ params }) => {
|
||||
try {
|
||||
await deleteWorkflow(params.name);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readdir, readFile, writeFile, unlink, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
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");
|
||||
|
||||
async function ensureDir() {
|
||||
await mkdir(WORKFLOW_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
||||
const conditionMap = new Map<string, string>();
|
||||
for (const [name, def] of Object.entries(payload.conditions)) {
|
||||
conditionMap.set(name, def.expression);
|
||||
}
|
||||
|
||||
const steps: WorkFlowSteps = [];
|
||||
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
||||
const graphTransitions = payload.graph[roleName] ?? [];
|
||||
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
|
||||
target: t.role === "$END" ? "END" : t.role,
|
||||
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
|
||||
}));
|
||||
|
||||
steps.push({
|
||||
role: {
|
||||
name: roleName,
|
||||
description: roleDef.description,
|
||||
identity: roleDef.goal,
|
||||
prepare: roleDef.capabilities.join("\n"),
|
||||
execute: roleDef.procedure,
|
||||
report: roleDef.output,
|
||||
},
|
||||
transitions,
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
const conditions: WorkflowPayload["conditions"] = {};
|
||||
const graph: Record<string, Transition[]> = {};
|
||||
|
||||
const expressionToName = new Map<string, string>();
|
||||
let condIdx = 0;
|
||||
|
||||
for (const step of steps) {
|
||||
const r = step.role;
|
||||
roles[r.name] = {
|
||||
description: r.description,
|
||||
goal: r.identity,
|
||||
capabilities: r.prepare ? r.prepare.split("\n").filter(Boolean) : [],
|
||||
procedure: r.execute,
|
||||
output: r.report,
|
||||
frontmatter: "",
|
||||
};
|
||||
|
||||
const transitions: Transition[] = step.transitions.map((t) => {
|
||||
let condName: string | null = null;
|
||||
if (t.condition) {
|
||||
if (expressionToName.has(t.condition)) {
|
||||
condName = expressionToName.get(t.condition)!;
|
||||
} else {
|
||||
condName = `cond${condIdx++}`;
|
||||
expressionToName.set(t.condition, condName);
|
||||
conditions[condName] = {
|
||||
description: "",
|
||||
expression: t.condition,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: t.target === "END" ? "$END" : t.target,
|
||||
condition: condName,
|
||||
};
|
||||
});
|
||||
|
||||
graph[r.name] = transitions;
|
||||
}
|
||||
|
||||
if (steps.length > 0) {
|
||||
graph["$START"] = [{ role: steps[0].role.name, condition: null }];
|
||||
}
|
||||
|
||||
return { name, description, roles, conditions, graph };
|
||||
}
|
||||
|
||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||
await ensureDir();
|
||||
const files = await readdir(WORKFLOW_DIR);
|
||||
const results: WorkflowSummary[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".yaml")) continue;
|
||||
const content = await readFile(join(WORKFLOW_DIR, file), "utf-8");
|
||||
const payload = YAML.parse(content) as WorkflowPayload;
|
||||
results.push({ name: payload.name, description: payload.description });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getWorkflow(name: string): Promise<WorkFlowSteps> {
|
||||
const content = await readFile(join(WORKFLOW_DIR, `${name}.yaml`), "utf-8");
|
||||
const payload = YAML.parse(content) as WorkflowPayload;
|
||||
return payloadToSteps(payload);
|
||||
}
|
||||
|
||||
export async function createWorkflow(name: string, description: string): Promise<void> {
|
||||
await ensureDir();
|
||||
const payload: WorkflowPayload = {
|
||||
name,
|
||||
description,
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
||||
}
|
||||
|
||||
export async function saveWorkflow(name: string, steps: WorkFlowSteps): Promise<void> {
|
||||
const filePath = join(WORKFLOW_DIR, `${name}.yaml`);
|
||||
let description = "";
|
||||
try {
|
||||
const existing = await readFile(filePath, "utf-8");
|
||||
const existingPayload = YAML.parse(existing) as WorkflowPayload;
|
||||
description = existingPayload.description;
|
||||
} catch {
|
||||
// file doesn't exist, use empty description
|
||||
}
|
||||
const payload = stepsToPayload(name, description, steps);
|
||||
await writeFile(filePath, YAML.stringify(payload), "utf-8");
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(name: string): Promise<void> {
|
||||
await unlink(join(WORKFLOW_DIR, `${name}.yaml`));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type WorkFlowRole = {
|
||||
name: string;
|
||||
description: string;
|
||||
identity: string;
|
||||
prepare: string;
|
||||
execute: string;
|
||||
report: string;
|
||||
};
|
||||
|
||||
export type WorkFlowTransition = {
|
||||
target: string;
|
||||
condition: string | null;
|
||||
};
|
||||
|
||||
export type WorkFlowStep = {
|
||||
role: WorkFlowRole;
|
||||
transitions: WorkFlowTransition[];
|
||||
};
|
||||
|
||||
export type WorkFlowSteps = WorkFlowStep[];
|
||||
|
||||
export type WorkflowSummary = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export function Layout(): ReactNode {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-background text-foreground">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<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
|
||||
)}
|
||||
{...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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,283 @@
|
||||
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';
|
||||
|
||||
type Reduce<T> = (data: T) => T;
|
||||
type Setter<T> = (ch: Reduce<T> | T) => void;
|
||||
|
||||
interface State<T, A> {
|
||||
readonly get: () => T;
|
||||
readonly set: Setter<T>;
|
||||
readonly use: () => T;
|
||||
readonly listen: (cb: VoidFunction) => VoidFunction;
|
||||
readonly actions: A;
|
||||
readonly onlyView: boolean;
|
||||
}
|
||||
type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
|
||||
type UseV = <T>(sub: SubModel<T, any>) => T;
|
||||
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
|
||||
|
||||
export const uuid = () => Math.round((Math.random() + 1) * Date.now()).toString(36);
|
||||
|
||||
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;
|
||||
if (Object.is(val, next)) return;
|
||||
val = next;
|
||||
listener.forEach(call => call());
|
||||
}
|
||||
const listen = (call: VoidFunction) => {
|
||||
listener.add(call);
|
||||
return () => listener.delete(call);
|
||||
};
|
||||
const use = () => useSyncExternalStore(listen, get, get);
|
||||
return { get, set, use, listen };
|
||||
}
|
||||
|
||||
class SubModel<T, A> {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private make: () => T,
|
||||
private create: Create<T, A>,
|
||||
private onlyView = false,
|
||||
) {}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
use(): [T, A] {
|
||||
const { query } = useContext(Context);
|
||||
const { use, actions } = query(this);
|
||||
return [use(), actions];
|
||||
}
|
||||
useData(): T {
|
||||
const { query } = useContext(Context);
|
||||
return query(this).use();
|
||||
}
|
||||
useCreation(): A {
|
||||
const { query } = useContext(Context);
|
||||
return query(this).actions;
|
||||
}
|
||||
}
|
||||
|
||||
type Snapshot = [name: string, data: any];
|
||||
class Model {
|
||||
private ustack: Snapshot[][] = [];
|
||||
private rstack: Snapshot[][] = [];
|
||||
private transaction = 0;
|
||||
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,
|
||||
) {}
|
||||
|
||||
public reset() {
|
||||
this.ustack = [];
|
||||
this.rstack = [];
|
||||
this.transaction = 0;
|
||||
this.backup.clear();
|
||||
this.triggerStackState();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
private getStackState = () => this.stackState;
|
||||
public useStackState() {
|
||||
const get = this.getStackState;
|
||||
return useSyncExternalStore(this.listenStackState, get, get);
|
||||
}
|
||||
|
||||
public log() {
|
||||
console.log('undo stack:', this.ustack);
|
||||
console.log('redo stack:', this.rstack);
|
||||
const snapshots: Record<string, any> = {};
|
||||
this.store.forEach((state, name) => {
|
||||
snapshots[name] = state.get();
|
||||
});
|
||||
console.log('current state:', snapshots);
|
||||
}
|
||||
|
||||
public undo() {
|
||||
const { ustack, rstack, store } = this;
|
||||
const item = ustack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
rstack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
|
||||
public redo() {
|
||||
const { ustack, rstack, store } = this;
|
||||
const item = rstack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
ustack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
|
||||
public canUndo() {
|
||||
return this.ustack.length > 0;
|
||||
}
|
||||
|
||||
public canRedo() {
|
||||
return this.rstack.length > 0;
|
||||
}
|
||||
|
||||
public startTransaction() {
|
||||
if (this.transaction === 0) {
|
||||
this.backup.clear();
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
this.backup.set(name, state.get());
|
||||
});
|
||||
}
|
||||
this.transaction += 1;
|
||||
return this.endTransaction;
|
||||
}
|
||||
|
||||
public endTransaction = () => {
|
||||
if (this.transaction === 0) return;
|
||||
this.transaction -= 1;
|
||||
if (this.transaction === 0) {
|
||||
const changes: Snapshot[] = [];
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
const before = this.backup.get(name);
|
||||
if (Object.is(before, state.get())) return;
|
||||
changes.push([name, before]);
|
||||
});
|
||||
this.backup.clear();
|
||||
if (changes.length === 0) return;
|
||||
this.ustack.push(changes);
|
||||
this.rstack.length = 0;
|
||||
this.triggerStackState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function build() {
|
||||
const store = new Map<string, State<any, any>>();
|
||||
|
||||
const mem: Record<string, any> = {};
|
||||
function use<T, A>(m: SubModel<T, A>): [T, A] {
|
||||
const state = query(m);
|
||||
return [state.get(), state.actions];
|
||||
}
|
||||
|
||||
const model = new Model(store, use);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// @ts-ignore
|
||||
window.__md__ = model;
|
||||
}
|
||||
|
||||
function query<T, A>(m: SubModel<T, A>): State<T, A> {
|
||||
const exist = store.get(m.name);
|
||||
if (exist) return exist as State<T, A>;
|
||||
const created = m.gen(model);
|
||||
store.set(m.name, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
return { query, model, mem, use }
|
||||
}
|
||||
|
||||
const Context = createContext(build());
|
||||
|
||||
export function useModel() {
|
||||
return useContext(Context).model;
|
||||
}
|
||||
|
||||
export function RegisterFlowToContext() {
|
||||
const { model } = useContext(Context);
|
||||
const instance = useReactFlow<AnyWorkNode>();
|
||||
useLayoutEffect(() => {
|
||||
model.flow = instance;
|
||||
}, [instance]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ModelProvider: FC<PropsWithChildren> = (p) => (
|
||||
<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);
|
||||
}
|
||||
|
||||
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 {
|
||||
return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
|
||||
}
|
||||
|
||||
function memoize<T>(init: (use: Use, model: Model) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
const { mem, model, use } = useContext(Context);
|
||||
const fn = mem[id] || (mem[id] = init(use, model));
|
||||
return fn as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compute<T>(calc: (use: UseV) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
const { mem, query } = useContext(Context);
|
||||
let state: ReturnType<typeof generate<T>> = mem[id];
|
||||
if (state) return state.use();
|
||||
|
||||
const deps = new Set<SubModel<any, any>>();
|
||||
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get());
|
||||
mem[id] = state = generate<T>(calc(usev));
|
||||
if (deps.size) {
|
||||
usev = m => query(m).get();
|
||||
const update = () => state.set(calc(usev));
|
||||
deps.forEach(m => query(m).listen(update));
|
||||
}
|
||||
return state.use();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const define = {
|
||||
model: defineModel,
|
||||
view: defineView,
|
||||
memoize,
|
||||
compute,
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
getSmoothStepPath,
|
||||
EdgeLabelRenderer,
|
||||
useReactFlow,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
} 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 { cn } from "../../lib/utils.ts";
|
||||
|
||||
const SOURCE_COLOR = "#10b981";
|
||||
const TARGET_COLOR = "#3b82f6";
|
||||
const LACK_COLOR = "#ff5252";
|
||||
const RADIUS = 12;
|
||||
|
||||
function GradientPath({
|
||||
id,
|
||||
path,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
hasCondition,
|
||||
selected,
|
||||
}: {
|
||||
id: string;
|
||||
path: string;
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
hasCondition: boolean | null;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const gradientId = `gradient-${id}`;
|
||||
const showLack = hasCondition === false;
|
||||
const strokeStyle = selected
|
||||
? { stroke: '#f59e0b', strokeWidth: 2 }
|
||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||
|
||||
return (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={sourceX}
|
||||
y1={sourceY}
|
||||
x2={targetX}
|
||||
y2={targetY}
|
||||
>
|
||||
<stop offset="0%" stopColor={showLack ? LACK_COLOR : SOURCE_COLOR} />
|
||||
<stop offset="100%" stopColor={showLack ? LACK_COLOR : TARGET_COLOR} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={20}
|
||||
className="react-flow__edge-interaction"
|
||||
/>
|
||||
<path
|
||||
id={id}
|
||||
d={path}
|
||||
fill="none"
|
||||
className="react-flow__edge-path"
|
||||
style={strokeStyle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
}}
|
||||
>
|
||||
<span className="inline-block px-1 bg-white rounded text-[10px] border border-gray-300 text-gray-500">
|
||||
else
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionLabelProps = {
|
||||
condition: string | undefined;
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
onSave: (value: string) => void;
|
||||
};
|
||||
|
||||
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function handleBadgeClick() {
|
||||
setInputValue(condition || "");
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (inputValue.trim()) {
|
||||
onSave(inputValue.trim());
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
handleSave();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleClickOutside(e: PointerEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("pointerdown", handleClickOutside, true);
|
||||
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute pointer-events-auto"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
zIndex: isOpen ? 1000 : undefined,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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",
|
||||
)}
|
||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
||||
>
|
||||
if
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1 z-50 bg-white rounded shadow-lg border border-gray-200 p-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input
|
||||
type="text"
|
||||
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
|
||||
placeholder="输入条件"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="p-0.5 text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<Check size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
||||
const siblings = allEdges.filter(e => e.source === source && e.type === 'conditional');
|
||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
||||
}
|
||||
|
||||
export function ConditionalEdge({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
data,
|
||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
});
|
||||
const flow = useReactFlow();
|
||||
const model = useModel();
|
||||
|
||||
const allEdges = flow.getEdges();
|
||||
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
|
||||
|
||||
const condition = data?.condition;
|
||||
function handleSave(value: string) {
|
||||
model.startTransaction();
|
||||
flow.updateEdgeData(id, { condition: value });
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GradientPath
|
||||
id={id}
|
||||
path={edgePath}
|
||||
sourceX={sourceX}
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={isElse ? null : (condition ? true : false)}
|
||||
selected={!!selected}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
{isElse
|
||||
? <ElseBadge labelX={labelX} labelY={labelY} />
|
||||
: <ConditionLabel condition={condition} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||
}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GradientEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
}: EdgeProps<Edge>): ReactNode {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
});
|
||||
|
||||
return (
|
||||
<GradientPath
|
||||
id={id}
|
||||
path={edgePath}
|
||||
sourceX={sourceX}
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={null}
|
||||
selected={!!selected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ConditionalEdge, GradientEdge } from './conditional';
|
||||
|
||||
export const edgeTypes = {
|
||||
conditional: ConditionalEdge,
|
||||
default: GradientEdge,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
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';
|
||||
|
||||
export * from './trans/type';
|
||||
|
||||
const proOptions = { hideAttribution: true };
|
||||
|
||||
const ReadonlyContext = createContext(false);
|
||||
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 readonly = useReadonly();
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%' }} tabIndex={0} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow<AnyWorkNode, Edge>
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={readonly ? undefined : onNodesChange}
|
||||
onEdgesChange={readonly ? undefined : onEdgesChange}
|
||||
onConnect={readonly ? undefined : onConnect}
|
||||
fitView
|
||||
proOptions={proOptions}
|
||||
onNodeDragStart={readonly ? undefined : onNodeDragStart}
|
||||
onNodeDragStop={readonly ? undefined : onNodeDragStop}
|
||||
onConnectEnd={readonly ? undefined : onConnectEnd}
|
||||
onBeforeDelete={readonly ? undefined : onBeforeDelete}
|
||||
onDelete={readonly ? undefined : onDelete}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodesDraggable={!readonly}
|
||||
nodesConnectable={!readonly}
|
||||
elementsSelectable={!readonly}
|
||||
>
|
||||
<RegisterFlowToContext />
|
||||
<Background />
|
||||
<Controls />
|
||||
{!readonly && <TopCenterPanel />}
|
||||
{!readonly && <Dialogs />}
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoFlow = memo(Flow);
|
||||
|
||||
interface Props {
|
||||
model: FlowModel;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
function Connect({ model }: { model: FlowModel }) {
|
||||
const { loadSteps } = handlers.use();
|
||||
const inject = injection.useCreation();
|
||||
const instance = model[InternalField];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return inject(instance);
|
||||
}, [instance]);
|
||||
|
||||
useEffect(() => {
|
||||
return instance.on('load', loadSteps);
|
||||
}, [instance]);
|
||||
|
||||
return <MemoFlow />;
|
||||
}
|
||||
|
||||
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>
|
||||
</ReadonlyContext.Provider>
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { WorkFlowSteps } from "./trans";
|
||||
import { Eventer } from './utils/eventer';
|
||||
|
||||
interface PublicEvents {
|
||||
save: WorkFlowSteps;
|
||||
}
|
||||
|
||||
interface PrivateEvents {
|
||||
load: WorkFlowSteps;
|
||||
}
|
||||
|
||||
export const InternalField = Symbol('InternalField');
|
||||
|
||||
export class Injection extends Eventer<PrivateEvents> {
|
||||
constructor(
|
||||
public readonly emitPublic: Eventer<PublicEvents>['emit'],
|
||||
private inital_steps?: WorkFlowSteps,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return off;
|
||||
};
|
||||
}
|
||||
|
||||
export class FlowModel {
|
||||
private readonly eventer = new Eventer<PublicEvents>();
|
||||
public on = this.eventer.on.bind(this.eventer);
|
||||
public off = this.eventer.off.bind(this.eventer);
|
||||
|
||||
public readonly [InternalField]: Injection;
|
||||
|
||||
constructor(inital_steps?: WorkFlowSteps) {
|
||||
this[InternalField] = new Injection(
|
||||
this.eventer.emit.bind(this.eventer),
|
||||
inital_steps,
|
||||
);
|
||||
}
|
||||
|
||||
public load(steps: WorkFlowSteps) {
|
||||
this[InternalField].emit('load', steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 120;
|
||||
const DEFAULT_NODE_HEIGHT = 50;
|
||||
const HORIZONTAL_GAP = 80; // 层与层之间的水平间距
|
||||
const VERTICAL_GAP = 40; // 同层节点之间的垂直间距
|
||||
|
||||
/**
|
||||
* 获取节点的尺寸
|
||||
*/
|
||||
function getNodeSize(node: Node): { width: number; height: number } {
|
||||
return {
|
||||
width: node.measured?.width ?? DEFAULT_NODE_WIDTH,
|
||||
height: node.measured?.height ?? DEFAULT_NODE_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建邻接表(出边)和入度表
|
||||
*/
|
||||
function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const outgoing = new Map<string, string[]>(); // nodeId -> [targetIds]
|
||||
const incoming = new Map<string, string[]>(); // nodeId -> [sourceIds]
|
||||
const inDegree = new Map<string, number>();
|
||||
|
||||
// 初始化
|
||||
for (const node of nodes) {
|
||||
outgoing.set(node.id, []);
|
||||
incoming.set(node.id, []);
|
||||
inDegree.set(node.id, 0);
|
||||
}
|
||||
|
||||
// 构建图
|
||||
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);
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { outgoing, incoming, inDegree };
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用拓扑排序将节点分层
|
||||
* - 'start' 节点固定在第 0 层
|
||||
* - 'end' 节点固定在最后一层
|
||||
* - 孤立节点放在中间层
|
||||
*/
|
||||
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');
|
||||
|
||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const currentLayer = layers.get(current)!;
|
||||
|
||||
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);
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按层级分组节点
|
||||
*/
|
||||
function groupByLayer<N extends Node>(nodes: N[], layers: Map<string, number>): Map<number, N[]> {
|
||||
const groups = new Map<number, N[]>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const layer = layers.get(node.id) ?? 0;
|
||||
if (!groups.has(layer)) {
|
||||
groups.set(layer, []);
|
||||
}
|
||||
groups.get(layer)!.push(node);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每层的最大宽度
|
||||
*/
|
||||
function calculateLayerWidths(layerGroups: Map<number, Node[]>): Map<number, number> {
|
||||
const widths = new Map<number, number>();
|
||||
|
||||
for (const [layer, nodesInLayer] of layerGroups) {
|
||||
let maxWidth = 0;
|
||||
for (const node of nodesInLayer) {
|
||||
const { width } = getNodeSize(node);
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
}
|
||||
widths.set(layer, maxWidth);
|
||||
}
|
||||
|
||||
return widths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每层的 X 起始位置
|
||||
*/
|
||||
function calculateLayerXPositions(
|
||||
layerWidths: Map<number, number>,
|
||||
maxLayer: number
|
||||
): Map<number, number> {
|
||||
const xPositions = new Map<number, number>();
|
||||
let currentX = 0;
|
||||
|
||||
for (let layer = 0; layer <= maxLayer; layer++) {
|
||||
xPositions.set(layer, currentX);
|
||||
const layerWidth = layerWidths.get(layer) ?? DEFAULT_NODE_WIDTH;
|
||||
currentX += layerWidth + HORIZONTAL_GAP;
|
||||
}
|
||||
|
||||
return xPositions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo: 1-N 情况下的布局优化
|
||||
* Todo: 如果计算完了之后,所有节点的位置都没变,则不更新节点,避免不必要的重渲染
|
||||
* node 中有 measured 属性,可以获得其尺寸,如果没有,则使用一个默认尺寸 120*50
|
||||
* edge 的 source 和 target 分别对应两端的 node 的 id
|
||||
*
|
||||
* 算法步骤:
|
||||
* 1. 使用拓扑排序将节点分层(从左到右)
|
||||
* 2. 计算每层的 X 位置
|
||||
* 3. 在每层内垂直居中排列节点
|
||||
*/
|
||||
export function LayoutLR<N extends Node>(nodes: N[], edges: Edge[]): N[] {
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1. 分配层级
|
||||
const layers = assignLayers(nodes, edges);
|
||||
|
||||
// 2. 按层级分组
|
||||
const layerGroups = groupByLayer(nodes, layers);
|
||||
|
||||
// 3. 计算每层宽度和 X 位置
|
||||
const maxLayer = Math.max(...layers.values());
|
||||
const layerWidths = calculateLayerWidths(layerGroups);
|
||||
const layerXPositions = calculateLayerXPositions(layerWidths, maxLayer);
|
||||
|
||||
// 4. 计算每层的总高度,用于垂直居中
|
||||
const layerHeights = new Map<number, number>();
|
||||
for (const [layer, nodesInLayer] of layerGroups) {
|
||||
let totalHeight = 0;
|
||||
for (const node of nodesInLayer) {
|
||||
const { height } = getNodeSize(node);
|
||||
totalHeight += height;
|
||||
}
|
||||
totalHeight += (nodesInLayer.length - 1) * VERTICAL_GAP;
|
||||
layerHeights.set(layer, totalHeight);
|
||||
}
|
||||
|
||||
// 找到最大高度,用于垂直居中对齐
|
||||
const maxHeight = Math.max(...layerHeights.values());
|
||||
|
||||
// 5. 为每个节点分配位置,并检查是否有变化
|
||||
const layoutedNodes: N[] = [];
|
||||
let hasChanged = false;
|
||||
|
||||
for (const [layer, nodesInLayer] of layerGroups) {
|
||||
const layerHeight = layerHeights.get(layer) ?? 0;
|
||||
const startY = (maxHeight - layerHeight) / 2; // 垂直居中
|
||||
const x = layerXPositions.get(layer) ?? 0;
|
||||
|
||||
let currentY = startY;
|
||||
|
||||
for (const node of nodesInLayer) {
|
||||
const { height } = getNodeSize(node);
|
||||
const newPosition = { x, y: currentY };
|
||||
if (node.position.x !== newPosition.x || node.position.y !== newPosition.y) {
|
||||
hasChanged = true;
|
||||
layoutedNodes.push({
|
||||
...node,
|
||||
position: newPosition,
|
||||
});
|
||||
} else {
|
||||
layoutedNodes.push(node);
|
||||
}
|
||||
currentY += height + VERTICAL_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanged ? layoutedNodes : nodes;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Edge } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import { edgesModel } from './edges';
|
||||
import type { RoleNodeData, AnyWorkNode } from '../type';
|
||||
|
||||
type ConnectHandle = {
|
||||
id?: string | null;
|
||||
nodeId: string;
|
||||
type: 'source' | 'target';
|
||||
};
|
||||
|
||||
export type AddNodeState = {
|
||||
fromNode: AnyWorkNode;
|
||||
fromHandle: ConnectHandle;
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
|
||||
type CommitParams = {
|
||||
data: RoleNodeData;
|
||||
};
|
||||
|
||||
function addNodeView() {
|
||||
return null as (AddNodeState | null);
|
||||
}
|
||||
|
||||
export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, get, model) => {
|
||||
function start(state: AddNodeState) {
|
||||
set(state);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
set(null);
|
||||
}
|
||||
|
||||
function commit(params: CommitParams) {
|
||||
const state = get();
|
||||
if (!state) return;
|
||||
set(null);
|
||||
|
||||
const { fromNode, fromHandle, position } = state;
|
||||
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 [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 };
|
||||
|
||||
model.startTransaction();
|
||||
model.use(nodesModel)[1].set((nds) => nds.concat(node));
|
||||
model.use(edgesModel)[1].set((eds) => eds.concat(newEdge));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { start, commit, cancel };
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
applyEdgeChanges,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
} 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';
|
||||
}
|
||||
|
||||
function isOutputHandle(handle: string | null | undefined): boolean {
|
||||
return handle === 'output' || handle === 'output-top' || handle === 'output-bottom';
|
||||
}
|
||||
|
||||
function normalizeConnection(params: Edge | Connection): Edge | Connection {
|
||||
if (isInputHandle(params.sourceHandle) && isOutputHandle(params.targetHandle)) {
|
||||
return {
|
||||
...params,
|
||||
source: params.target,
|
||||
sourceHandle: params.targetHandle ?? null,
|
||||
target: params.source,
|
||||
targetHandle: params.sourceHandle ?? null,
|
||||
} as Edge | Connection;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
let edgeCounter = 0;
|
||||
|
||||
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))) {
|
||||
model.startTransaction();
|
||||
set((eds) => applyEdgeChanges(changes, eds));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
return;
|
||||
}
|
||||
set((eds) => applyEdgeChanges(changes, eds));
|
||||
}
|
||||
|
||||
function onConnect(params: Edge | Connection) {
|
||||
const normalized = normalizeConnection(params);
|
||||
|
||||
if (normalized.source === normalized.target) return;
|
||||
|
||||
if (!isOutputHandle(normalized.sourceHandle) || !isInputHandle(normalized.targetHandle)) return;
|
||||
|
||||
const currentEdges = get();
|
||||
const duplicate = currentEdges.some(
|
||||
e => e.source === normalized.source && e.target === normalized.target,
|
||||
);
|
||||
if (duplicate) return;
|
||||
|
||||
model.startTransaction();
|
||||
|
||||
const id = `e-${normalized.source}-${normalized.target}-${++edgeCounter}`;
|
||||
const edge: Edge = {
|
||||
...normalized,
|
||||
id,
|
||||
animated: true,
|
||||
} as Edge;
|
||||
|
||||
const existingFromSource = currentEdges.filter(e => e.source === normalized.source);
|
||||
|
||||
if (existingFromSource.length > 0) {
|
||||
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: '' } };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
set([...promoted, edge]);
|
||||
} else {
|
||||
set((eds) => [...eds, edge]);
|
||||
}
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { onEdgesChange, onConnect, set };
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import type { RoleNodeData, WorkNode } from '../type';
|
||||
|
||||
export type EditNodeState = {
|
||||
node: WorkNode<'role'>;
|
||||
};
|
||||
|
||||
function editNodeView() {
|
||||
return null as (EditNodeState | null);
|
||||
}
|
||||
|
||||
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'> });
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
set(null);
|
||||
}
|
||||
|
||||
function commit(data: RoleNodeData) {
|
||||
const state = get();
|
||||
if (!state) return;
|
||||
set(null);
|
||||
|
||||
const { editNode } = model.use(nodesModel)[1];
|
||||
|
||||
model.startTransaction();
|
||||
editNode(state.node.id, (node) => {
|
||||
node.data = data as any;
|
||||
});
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { start, commit, cancel };
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
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';
|
||||
|
||||
export const handlers = define.memoize((use, model) => {
|
||||
const onNodeDragStart: OnNodeDrag<AnyWorkNode> = () => {
|
||||
model.startTransaction();
|
||||
};
|
||||
const onNodeDragStop: OnNodeDrag<AnyWorkNode> = () => {
|
||||
model.endTransaction();
|
||||
};
|
||||
const onConnectEnd: OnConnectEnd = (event, state) => {
|
||||
const { isValid, to, fromHandle, fromNode } = state;
|
||||
if (isValid) return;
|
||||
if (!to || !fromHandle || !fromNode) return;
|
||||
const { clientX, clientY } = event as MouseEvent;
|
||||
use(addNodeViewModel)[1].start({
|
||||
fromNode: fromNode as any as AnyWorkNode,
|
||||
fromHandle: fromHandle,
|
||||
position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }),
|
||||
});
|
||||
};
|
||||
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'start' || node.type === 'end') {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
model.startTransaction();
|
||||
return true;
|
||||
};
|
||||
const onDelete: OnDelete = ({ edges: deletedEdges }) => {
|
||||
if (deletedEdges.length > 0) {
|
||||
const currentEdges = use(edgesModel)[0];
|
||||
const sourcesToCheck = new Set(
|
||||
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');
|
||||
if (siblings.length === 1) {
|
||||
needsDowngrade = true;
|
||||
const { data: _, ...rest } = e;
|
||||
return { ...rest, type: 'default' as const };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
if (needsDowngrade) {
|
||||
use(edgesModel)[1].set(updatedEdges);
|
||||
}
|
||||
}
|
||||
}
|
||||
model.endTransaction();
|
||||
};
|
||||
|
||||
function autoLayoutLR() {
|
||||
const [nodes, { set }] = use(nodesModel);
|
||||
const edges = use(edgesModel)[0];
|
||||
|
||||
const layoutedNodes = LayoutLR(nodes, edges);
|
||||
model.startTransaction();
|
||||
set(layoutedNodes);
|
||||
model.endTransaction();
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
use(addNodeViewModel)[1].cancel();
|
||||
use(editNodeViewModel)[1].cancel();
|
||||
}
|
||||
|
||||
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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSteps(steps: WorkFlowSteps) {
|
||||
resetView();
|
||||
const { nodes, edges } = transIn(steps);
|
||||
use(nodesModel)[1].set(nodes);
|
||||
use(edgesModel)[1].set(edges);
|
||||
autoLayoutLR();
|
||||
model.reset();
|
||||
}
|
||||
|
||||
function saveData() {
|
||||
const nodes = use(nodesModel)[0];
|
||||
const edges = use(edgesModel)[0];
|
||||
const result = validate(nodes, edges);
|
||||
if (result.valid) {
|
||||
const steps = transOut(nodes, edges);
|
||||
const instance = use(injection)[0];
|
||||
instance.emitPublic('save', steps);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
onNodeDragStart,
|
||||
onNodeDragStop,
|
||||
onConnectEnd,
|
||||
onBeforeDelete,
|
||||
onDelete,
|
||||
autoLayoutLR,
|
||||
handleKeyDown,
|
||||
loadSteps,
|
||||
saveData,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +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';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user