Compare commits

..

49 Commits

Author SHA1 Message Date
xiaoju c9507b8dc1 fix: add git worktree hygiene to solve-issue workflow
Developer: checkout main + create fresh branch before coding.
Reviewer: verify branch matches issue before reviewing.

Fixes #395

小橘 <xiaoju@shazhou.work>
2026-05-22 10:59:08 +00:00
xiaomo baa2edfa38 Merge pull request 'feat: workflow-agent-claude-code' (#393) from feat/391-workflow-agent-claude-code into main 2026-05-22 10:58:18 +00:00
xingyue 4dff320d5c fix: throw on non-JSON Claude Code output instead of fallback 2026-05-22 18:57:07 +08:00
scottwei d8863ceda2 Merge pull request 'init workflow dashboard' (#387) from jshang/workflow-dashboard into main
Reviewed-on: #387
2026-05-22 10:54:14 +00:00
scottwei c9fcb15384 Merge branch 'main' into jshang/workflow-dashboard 2026-05-22 10:52:53 +00:00
xiaomo 5e868a2977 Merge pull request 'fix: explicitly forbid extra frontmatter fields in output format instruction' (#396) from fix/394-forbid-extra-frontmatter-fields into main 2026-05-22 10:51:52 +00:00
xiaoju 76fab22827 fix: explicitly forbid extra frontmatter fields in output format instruction
buildOutputFormatInstruction now includes explicit language telling agents to
output ONLY schema-defined fields and to focus on their role's deliverable.

Fixes #394
2026-05-22 10:49:04 +00:00
xingyue 176844d7f5 fix: add sessionId to raw fallback, fix test meta→frontmatter+description 2026-05-22 18:42:27 +08:00
xingyue 31695e89a8 feat: add workflow-agent-claude-code package
Claude Code CLI adapter for the workflow engine, mirroring
workflow-agent-hermes architecture. Spawns `claude -p` with
`--output-format json` for structured output parsing.

Refs #391
2026-05-22 18:38:18 +08:00
xiaomo 669875fb46 Merge pull request 'feat: validate model connectivity during uwf setup' (#392) from feat/335-setup-validate-model into main 2026-05-22 10:32:01 +00:00
xiaoju 6d94be34a9 feat: validate model connectivity during uwf setup
Send a test completion request after configuration to verify the model
is reachable. If validation fails, warn the user and suggest trying a
different model or checking their settings.

Fixes #335
2026-05-22 10:30:39 +00:00
xiaomo d95fe45a3d Merge pull request 'feat: add --count/-c flag to uwf thread step' (#390) from feat/373-thread-step-count into main 2026-05-22 10:11:13 +00:00
xiaoju b9252b5ce2 fix: dynamic frontmatter instruction from role schema (closes #389) 2026-05-22 10:03:56 +00:00
xiaoju 4d47effd39 fix: generate frontmatter instruction dynamically from role schema
Replace hardcoded 5-field example with schema-driven generation.
Now shows actual enum values, types, and required markers for
each role's frontmatter schema.

Fixes #389

小橘 <xiaoju@shazhou.work>
2026-05-22 10:03:45 +00:00
xiaoju 7b93ce8f3e fix: dynamic frontmatter field extraction from role schema (closes #388) 2026-05-22 09:57:45 +00:00
xiaoju 67870392ab fix: dynamic frontmatter field extraction from role schema
Replace hardcoded 5-field candidate with schema-driven extraction.
Now reads outputSchema properties and picks matching fields from
parsed frontmatter, supporting role-specific fields like plan,
approved, success.

Falls back to standard 5 fields when schema has no properties.

Fixes #388

小橘 <xiaoju@shazhou.work>
2026-05-22 09:57:30 +00:00
jiashuang 9316b843f6 init workflow service 2026-05-22 17:46:53 +08:00
xiaomo 6b9ff9781d Merge pull request 'fix: revert unnecessary output protocol changes from #385' (#386) from fix/385-revert-output-protocol into main 2026-05-22 09:40:33 +00:00
xiaoju 487c48effa fix: revert output protocol changes from #385
Agent CLI outputs plain CAS hash (not JSON), engine parses plain hash.
StepOutput no longer carries sessionId — session info is already in CAS detail.
Keeps the valuable parts of #385: sessionId in AgentRunResult (process-internal),
continue support, and frontmatter retry loop.
2026-05-22 09:39:36 +00:00
xiaomo 4eca2d533c Merge pull request 'feat: agent session protocol — sessionId, continue, frontmatter retry' (#385) from feat/384-agent-session-protocol into main 2026-05-22 09:20:35 +00:00
xiaoju f0f840e6e0 fix: StepOutput.sessionId → string | null, legacy fallback → null 2026-05-22 09:16:13 +00:00
xiaoju 7ff90cef4f feat: agent session protocol — sessionId in result, continue support, frontmatter retry
Breaking changes:
- AgentRunResult now requires sessionId field
- AgentOptions now requires continue function
- Agent CLI outputs JSON {stepHash, sessionId} instead of plain CAS hash
- Engine parses JSON output (with legacy CAS hash fallback)

New features:
- Frontmatter validation retry: if agent output lacks valid frontmatter,
  engine calls agent.continue() up to 2 times with correction message
- Session tracking: sessionId flows from agent → engine → StepOutput
- Hermes agent: session parse failure is now a hard error (no raw text fallback)
- Hermes agent: supports --resume for continue sessions

Closes #384
2026-05-22 09:13:05 +00:00
xiaoju e62d51d845 Merge remote-tracking branch 'origin/feat/remove-llm-extract' into feat/384-agent-session-protocol 2026-05-22 09:06:24 +00:00
xiaoju a803fcb4fc fix: solve-issue.yaml meta.plan → frontmatter.plan
Follows #375 rename.
2026-05-22 09:04:34 +00:00
xiaomo d00c93fc19 Merge pull request 'feat: uwf cas put-text for storing plain text in CAS' (#382) from feat/cas-put-text into main 2026-05-22 09:02:09 +00:00
xiaoju 99a2890be2 feat: remove LLM extract fallback, require YAML frontmatter
Agent output must contain valid YAML frontmatter matching the role schema.
If frontmatter parsing fails, the step fails immediately with a clear error
instead of falling back to an LLM extraction that can fabricate values.

The extract module remains as a public API export but is no longer used
in the agent run loop.

Breaking change: agents that relied on LLM extraction to produce valid
output will now fail. They must output proper frontmatter.
2026-05-22 08:58:01 +00:00
xiaoju 3b7d0564bb feat: uwf cas put-text for storing plain text in CAS
- Register built-in text schema ({type: 'string'}) alongside workflow schemas
- Add cmdCasPutText command: uwf cas put-text <text>
- Update CLI reference in workflow-util
- Update solve-issue.yaml procedure to use put-text

Refs #380
2026-05-22 08:53:27 +00:00
xiaoju 45dacf540b feat: thread step --count/-c <number> to run multiple steps
Add --count/-c flag to 'uwf thread step' for running N steps in one
invocation, stopping early if $END is reached.

- cmdThreadStep now loops up to count times, delegates to cmdThreadStepOnce
- CLI parses -c/--count, defaults to 1 (backward compatible single output)
- Validation rejects 0, negative, and non-integer counts
- 7 new tests covering CLI parsing and count validation

Fixes #373

Co-authored-by: uwf-hermes (solve-issue workflow)
2026-05-22 08:06:26 +00:00
xiaomo 2eb5ee0666 Merge pull request 'fix: accept omitted condition in fallback transitions' (#378) from fix/fallback-transition-validation into main 2026-05-22 07:56:18 +00:00
xiaoju e67932c83c fix: accept omitted condition in fallback transitions
Fallback transitions (last entry in graph node) omit the condition
field in YAML, resulting in undefined instead of null. The validator
and materializer now handle this:

- validate.ts: accept undefined as valid condition value
- workflow.ts: normalizeGraph() coerces undefined → null before CAS put

This was broken by the graph fallback pattern introduced in #370.
2026-05-22 07:38:24 +00:00
xiaomo 04a12231c3 Merge pull request 'feat: register $first/$last JSONata functions in moderator' (#377) from feat/376-first-last-jsonata into main 2026-05-22 07:32:17 +00:00
xiaoju e5ae9a134c feat: register $first/$last JSONata functions in moderator
Register custom $first(role) and $last(role) functions in the JSONata
evaluator. These search the steps array and return the matching role's
frontmatter (output) directly, replacing verbose steps[-1].output.x
expressions with semantic $last('role').field syntax.

- workflow-moderator: register functions via expr.registerFunction()
- Updated all condition expressions in .workflows/ and examples/
- Added tests for $last, $first, and unmatched role (undefined)

Fixes #376
2026-05-22 06:29:56 +00:00
xiaomo bdafaf3aa1 Merge pull request 'refactor!: rename RoleDefinition.meta → frontmatter' (#375) from refactor/374-meta-to-frontmatter into main 2026-05-22 06:06:06 +00:00
xiaoju 02f7f0b708 refactor!: rename RoleDefinition.meta → frontmatter
BREAKING CHANGE: All workflow YAML files must use 'frontmatter' instead of 'meta'.

- workflow-protocol: RoleDefinition.meta → frontmatter, schema updated
- cli-workflow: validate.ts, workflow.ts — resolveMetaRef → resolveFrontmatterRef
- workflow-agent-kit: run.ts — metaSchema → frontmatterSchema
- All YAML files updated (examples/, .workflows/)

Fixes #374
2026-05-22 06:05:07 +00:00
xiaoju 8ea554bb5e Merge pull request 'feat: create .workflows/solve-issue.yaml' (#372) from feat/370-solve-issue-workflow into main 2026-05-22 06:02:15 +00:00
xiaoju 8a425521da fix: output instructions now specify required frontmatter meta fields 2026-05-22 05:42:17 +00:00
xiaoju f174f2fd0a fix: remove redundant condition null from $START 2026-05-22 05:33:39 +00:00
xiaoju 355594d074 refactor: graph fallback pattern + positive condition names
- Last transition in each graph node is now the fallback (no condition)
- Remove redundant positive conditions (ready, devDone, approved, passed, pushSuccess)
- notApproved → rejected (positive naming)
2026-05-22 05:31:43 +00:00
xiaoju fd7609fe90 fix: address review feedback from xingyue
1. npm/npx → bun/bunx (project standard)
2. Fix tea CLI usage (tea comment + -r flag)
3. cursor-agent → coding (abstract capability)
4. Clarify committer inherits developer's worktree
5. Mark meta.plan required when status=ready
6. PR description must follow What/Why/Changes/Ref template
7. Note maxRounds loop protection in description
2026-05-22 05:27:21 +00:00
xiaoju dacecfbbb7 feat: create .workflows/solve-issue.yaml
TDD-driven issue resolution workflow with 5 roles:
- planner: analyzes issue, outputs TDD test spec (stored in CAS)
- developer: implements code following TDD
- reviewer: code standards compliance check (not functionality)
- tester: functional correctness verification
- committer: commits and creates PR

Graph handles bounce-backs: reviewer→developer, tester→developer,
tester→planner (fix_spec), committer→developer (hook_failed).

Refs #370
2026-05-22 05:21:19 +00:00
xiaomo 3238eaeddf Merge pull request 'feat: add uwf skill cli command and Prepare section' (#371) from feat/369-uwf-skill-cli into main 2026-05-22 04:50:12 +00:00
xiaoju 995f273fa5 address review: move CLI reference to workflow-util, inline in prompt
- Move generateCliReference() to @uncaged/workflow-util
- buildRolePrompt inlines CLI reference directly (no agent tool call)
- Fix Role terminology to use new field names
- Add maintenance comment in cli-reference.ts
- Fix test assertions
2026-05-22 03:29:01 +00:00
xiaoju 866154ad73 feat: add uwf skill cli command and Prepare section in role prompt
- Add 'uwf skill cli' command that prints markdown CLI reference
- buildRolePrompt now generates ## Prepare section:
  - Always prompts agent to run 'uwf skill cli' (explicit skill)
  - Renders capabilities as keyword hints for implicit skill loading

Fixes #369
2026-05-22 03:20:04 +00:00
xiaomo 8efc5050cb Merge pull request 'chore: exclude legacy code from biome check' (#368) from chore/ignore-legacy-biome into main 2026-05-22 02:10:20 +00:00
xiaoju 3fb60ee649 chore: exclude legacy-packages and scripts from biome check
- Add legacy-packages/ and scripts/ to biome ignore
- Allow noDefaultExport in vitest.config.* and .d.ts
- Allow console in cli.ts and setup.ts (CLI user output)
- Fix unused imports in cas.ts and setup.ts
2026-05-22 02:09:18 +00:00
xiaomo e181f67a2d Merge pull request 'feat: support project-local workflow discovery' (#367) from feat/365-project-local-workflows into main 2026-05-22 02:07:33 +00:00
xiaoju a3114bf840 chore: apply biome formatting across codebase 2026-05-22 02:06:05 +00:00
xiaoju e59ae9aca1 feat: support project-local workflow discovery
- Add .workflows/*.yaml scanning from project root (cwd)
- Resolution: project-local first, then global registry
- On-the-fly CAS materialization for local workflows
- Filename/name consistency check
- uwf workflow list shows origin (local/global)

Fixes #365
2026-05-22 01:01:45 +00:00
xiaomo c050a38f38 Merge pull request 'refactor: rename RoleDefinition fields for clarity' (#366) from refactor/364-rename-role-fields into main 2026-05-22 00:48:23 +00:00
118 changed files with 6630 additions and 283 deletions
+2
View File
@@ -11,3 +11,5 @@ solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
.DS_Store
*.py
.claude
tmp
+83
View File
@@ -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`
+178
View File
@@ -0,0 +1,178 @@
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: |
Before starting any work, ensure a clean worktree:
1. `git checkout main && git pull` to get the latest code
2. `git checkout -b fix/<issue-number>-<short-description>` to create a fresh branch
- If bounced back from reviewer or tester, reuse the existing branch instead
Then implement TDD:
3. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's meta.plan)
4. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
5. Write tests first based on the spec
6. Implement the code to make tests pass
7. Ensure `bun run build` passes with no errors
8. Run `bun test` to verify all tests pass
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: |
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
2. If the branch doesn't correspond to the issue, flag it in your output and reject
Then perform code review:
Hard checks (must all pass):
3. `bun run build` — no build errors
4. `bunx biome check` — no lint violations
5. 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"
+13 -1
View File
@@ -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": {
+1 -1
View File
@@ -19,7 +19,7 @@ roles:
output: |
Provide your analysis as markdown under the frontmatter.
The frontmatter must include your structured findings.
meta:
frontmatter:
type: object
properties:
thesis:
+4 -4
View File
@@ -9,7 +9,7 @@ roles:
- planning
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
output: "Output the plan summary and list of concrete steps."
meta:
frontmatter:
type: object
properties:
plan:
@@ -28,7 +28,7 @@ roles:
- 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."
meta:
frontmatter:
type: object
properties:
filesChanged:
@@ -46,7 +46,7 @@ roles:
- 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."
meta:
frontmatter:
type: object
properties:
approved:
@@ -57,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": {
@@ -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");
});
});
+38 -7
View File
@@ -7,6 +7,7 @@ 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,
@@ -47,7 +49,10 @@ const program = new Command();
// 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
.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");
@@ -82,7 +87,7 @@ workflow
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowList(storageRoot);
const result = await cmdWorkflowList(storageRoot, process.cwd());
writeOutput(result);
});
});
@@ -97,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);
}
});
});
@@ -217,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")
@@ -282,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")
+17 -24
View File
@@ -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
View File
@@ -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";
+73 -3
View File
@@ -1,4 +1,5 @@
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/workflow-agent-kit";
@@ -24,21 +25,24 @@ import type {
} 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 = {
@@ -624,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];
+60 -16
View File
@@ -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/workflow-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,30 +51,41 @@ function isJsonSchema(value: unknown): value is JSONSchema {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function resolveMetaRef(
/** 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,
meta: unknown,
frontmatter: unknown,
): Promise<CasRef> {
if (!isJsonSchema(meta)) {
fail(`role "${roleName}": meta must be a JSON Schema object`);
if (!isJsonSchema(frontmatter)) {
fail(`role "${roleName}": frontmatter must be a JSON Schema object`);
}
const schema: JSONSchema = meta.title === undefined
? { ...meta, title: roleName }
: meta;
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 meta = await resolveMetaRef(
const frontmatter = await resolveFrontmatterRef(
uwf,
`${raw.name}.${roleName}`,
role.meta,
role.frontmatter,
);
roles[roleName] = {
description: role.description,
@@ -73,7 +93,7 @@ async function materializeWorkflowPayload(
capabilities: role.capabilities,
procedure: role.procedure,
output: role.output,
meta,
frontmatter,
};
}
return {
@@ -81,7 +101,7 @@ async function materializeWorkflowPayload(
description: raw.description,
roles,
conditions: raw.conditions,
graph: raw.graph,
graph: normalizeGraph(raw.graph),
};
}
@@ -108,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);
@@ -150,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;
}
+7 -7
View File
@@ -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/workflow-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 };
}
+55 -1
View File
@@ -1,4 +1,4 @@
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";
@@ -11,6 +11,44 @@ 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");
@@ -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) {
+35 -4
View File
@@ -1,3 +1,4 @@
import { basename } from "node:path";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
@@ -14,8 +15,8 @@ function isRoleDefinition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const meta = value.meta;
const metaOk = isRecord(meta) && typeof meta.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");
@@ -25,7 +26,7 @@ function isRoleDefinition(value: unknown): boolean {
capabilitiesOk &&
typeof value.procedure === "string" &&
typeof value.output === "string" &&
metaOk
frontmatterOk
);
}
@@ -41,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 {
@@ -60,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)) {
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit";
import { buildClaudeCodePrompt } from "../src/claude-code.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
return {
workflow: {
roles: {
developer: {
description: "TDD implementation per test spec",
goal: "Write code",
capabilities: ["coding"],
procedure: "1. Read spec\n2. Write code",
output: "List files changed",
frontmatter: "",
},
},
conditions: {},
graph: {},
},
role: "developer",
start: { prompt: "Fix the bug", workflowHash: "abc123", threadId: "t1" },
steps: [],
store: {} as AgentContext["store"],
outputFormatInstruction: "Use YAML frontmatter",
...overrides,
};
}
describe("buildClaudeCodePrompt", () => {
test("assembles outputFormatInstruction + role prompt + task prompt", () => {
const result = buildClaudeCodePrompt(makeCtx());
expect(result).toMatch(/^Use YAML frontmatter/);
expect(result).toContain("Write code");
expect(result).toContain("## Task\nFix the bug");
});
test("includes previous steps as history summary", () => {
const ctx = makeCtx({
steps: [{ role: "planner", output: '{"plan":"do X"}', agent: "hermes" }],
});
const result = buildClaudeCodePrompt(ctx);
expect(result).toContain("## Previous Steps");
expect(result).toContain("Step 1: planner");
expect(result).toContain("do X");
});
test("omits history section when steps array is empty", () => {
const result = buildClaudeCodePrompt(makeCtx({ steps: [] }));
expect(result).not.toContain("## Previous Steps");
});
test("works without outputFormatInstruction", () => {
const result = buildClaudeCodePrompt(makeCtx({ outputFormatInstruction: "" }));
expect(result).not.toMatch(/^\s*\n/);
expect(result).toContain("Write code");
expect(result).toContain("## Task");
});
});
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, walk } from "@uncaged/json-cas";
import {
parseClaudeCodeJsonOutput,
storeClaudeCodeDetail,
storeClaudeCodeRawOutput,
} from "../src/session-detail.js";
import type { ClaudeCodeParsedResult } from "../src/types.js";
describe("parseClaudeCodeJsonOutput", () => {
test("parses valid claude -p --output-format json output", () => {
const stdout = JSON.stringify({
type: "result",
subtype: "success",
result: "Done fixing bug",
session_id: "75e2167f-abc",
num_turns: 3,
total_cost_usd: 0.08,
duration_ms: 10276,
});
const parsed = parseClaudeCodeJsonOutput(stdout);
expect(parsed).not.toBeNull();
expect(parsed!.type).toBe("result");
expect(parsed!.subtype).toBe("success");
expect(parsed!.result).toBe("Done fixing bug");
expect(parsed!.sessionId).toBe("75e2167f-abc");
expect(parsed!.numTurns).toBe(3);
expect(parsed!.totalCostUsd).toBe(0.08);
expect(parsed!.durationMs).toBe(10276);
});
test("parses error_max_turns result", () => {
const stdout = JSON.stringify({
type: "result",
subtype: "error_max_turns",
result: "Ran out of turns",
session_id: "abc-def",
num_turns: 90,
total_cost_usd: 1.5,
duration_ms: 50000,
});
const parsed = parseClaudeCodeJsonOutput(stdout);
expect(parsed).not.toBeNull();
expect(parsed!.subtype).toBe("error_max_turns");
expect(parsed!.result).toBe("Ran out of turns");
});
test("returns null for non-JSON output", () => {
const parsed = parseClaudeCodeJsonOutput("Some random text\nwithout JSON");
expect(parsed).toBeNull();
});
test("returns null when session_id is missing", () => {
const stdout = JSON.stringify({ type: "result", result: "hi", subtype: "success" });
const parsed = parseClaudeCodeJsonOutput(stdout);
expect(parsed).toBeNull();
});
});
describe("storeClaudeCodeDetail", () => {
test("stores claude-code-detail CAS node and returns output + detailHash", async () => {
const store = createMemoryStore();
const parsed: ClaudeCodeParsedResult = {
type: "result",
subtype: "success",
result: "The answer",
sessionId: "abc-123",
numTurns: 5,
totalCostUsd: 0.12,
durationMs: 15000,
};
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
expect(detailHash).toHaveLength(13);
expect(output).toBe("The answer");
expect(sessionId).toBe("abc-123");
const node = await store.get(detailHash);
expect(node).not.toBeNull();
expect(node!.payload.sessionId).toBe("abc-123");
expect(node!.payload.numTurns).toBe(5);
expect(node!.payload.totalCostUsd).toBe(0.12);
expect(node!.payload.durationMs).toBe(15000);
});
test("detail node is walkable from root", async () => {
const store = createMemoryStore();
const parsed: ClaudeCodeParsedResult = {
type: "result",
subtype: "success",
result: "walkable test",
sessionId: "walk-123",
numTurns: 1,
totalCostUsd: 0.01,
durationMs: 1000,
};
const { detailHash } = await storeClaudeCodeDetail(store, parsed);
const visited: string[] = [];
walk(store, detailHash, (hash) => visited.push(hash));
expect(visited.length).toBeGreaterThan(0);
});
});
describe("storeClaudeCodeRawOutput", () => {
test("stores raw text when JSON parsing fails", async () => {
const store = createMemoryStore();
const rawText = "Claude produced plain text without JSON";
const hash = await storeClaudeCodeRawOutput(store, rawText);
expect(hash).toHaveLength(13);
const node = await store.get(hash);
expect(node).not.toBeNull();
expect(node!.payload.text).toBe(rawText);
});
});
@@ -0,0 +1,33 @@
{
"name": "@uncaged/workflow-agent-claude-code",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uwf-claude-code": "./src/cli.ts"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.4.0",
"@uncaged/workflow-agent-kit": "workspace:^"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,148 @@
import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas";
import {
type AgentContext,
type AgentRunResult,
buildRolePrompt,
createAgent,
} from "@uncaged/workflow-agent-kit";
import { parseClaudeCodeJsonOutput, storeClaudeCodeDetail } from "./session-detail.js";
const CLAUDE_COMMAND = "claude";
const CLAUDE_MAX_TURNS = 90;
function buildHistorySummary(steps: AgentContext["steps"]): string {
if (steps.length === 0) {
return "";
}
const lines: string[] = ["## Previous Steps"];
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (step === undefined) {
continue;
}
lines.push("");
lines.push(`### Step ${i + 1}: ${step.role}`);
lines.push(`Output: ${JSON.stringify(step.output)}`);
lines.push(`Agent: ${step.agent}`);
}
return lines.join("\n");
}
/** Assemble system prompt, task, and prior step outputs for Claude Code. */
export function buildClaudeCodePrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
const parts: string[] = [];
if (ctx.outputFormatInstruction !== undefined && ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
const historyBlock = buildHistorySummary(ctx.steps);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
return parts.join("\n");
}
function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(CLAUDE_COMMAND, args, {
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", (cause) => {
const message = cause instanceof Error ? cause.message : String(cause);
reject(new Error(`claude spawn failed: ${message}`));
});
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
reject(new Error(`claude exited with code ${code ?? "null"}${detail}`));
});
});
}
function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> {
return spawnClaude([
"-p",
prompt,
"--output-format",
"json",
"--dangerously-skip-permissions",
"--max-turns",
String(CLAUDE_MAX_TURNS),
]);
}
function spawnClaudeResume(
sessionId: string,
message: string,
): Promise<{ stdout: string; stderr: string }> {
return spawnClaude([
"-p",
message,
"--resume",
sessionId,
"--output-format",
"json",
"--dangerously-skip-permissions",
"--max-turns",
String(CLAUDE_MAX_TURNS),
]);
}
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
const parsed = parseClaudeCodeJsonOutput(stdout);
if (parsed !== null) {
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
return { output, detailHash, sessionId };
}
throw new Error(
`Claude Code returned non-JSON output (first 200 chars): ${stdout.slice(0, 200)}`,
);
}
async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildClaudeCodePrompt(ctx);
const { stdout } = await spawnClaudeRun(fullPrompt);
return processClaudeOutput(stdout, ctx.store);
}
async function continueClaudeCode(
sessionId: string,
message: string,
store: Store,
): Promise<AgentRunResult> {
const { stdout } = await spawnClaudeResume(sessionId, message);
return processClaudeOutput(stdout, store);
}
/** Agent CLI factory: parses argv, runs Claude Code, extracts output, writes StepNode. */
export function createClaudeCodeAgent(): () => Promise<void> {
return createAgent({
name: "claude-code",
run: runClaudeCode,
continue: continueClaudeCode,
});
}
@@ -0,0 +1,6 @@
#!/usr/bin/env bun
import { createClaudeCodeAgent } from "./claude-code.js";
const main = createClaudeCodeAgent();
void main();
@@ -0,0 +1,6 @@
export { buildClaudeCodePrompt, createClaudeCodeAgent } from "./claude-code.js";
export {
parseClaudeCodeJsonOutput,
storeClaudeCodeDetail,
storeClaudeCodeRawOutput,
} from "./session-detail.js";
@@ -0,0 +1,25 @@
import type { JSONSchema } from "@uncaged/json-cas";
export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = {
title: "claude-code-detail",
type: "object",
required: ["sessionId", "numTurns", "totalCostUsd", "durationMs", "subtype"],
properties: {
sessionId: { type: "string" },
numTurns: { type: "integer" },
totalCostUsd: { type: "number" },
durationMs: { type: "integer" },
subtype: { type: "string" },
},
additionalProperties: false,
};
export const CLAUDE_CODE_RAW_OUTPUT_SCHEMA: JSONSchema = {
title: "claude-code-raw-output",
type: "object",
required: ["text"],
properties: {
text: { type: "string" },
},
additionalProperties: false,
};
@@ -0,0 +1,79 @@
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { CLAUDE_CODE_DETAIL_SCHEMA, CLAUDE_CODE_RAW_OUTPUT_SCHEMA } from "./schemas.js";
import type { ClaudeCodeDetailPayload, ClaudeCodeParsedResult } from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Parse Claude Code JSON stdout (`claude -p --output-format json`). */
export function parseClaudeCodeJsonOutput(stdout: string): ClaudeCodeParsedResult | null {
let parsed: unknown;
try {
parsed = JSON.parse(stdout.trim());
} catch {
return null;
}
if (!isRecord(parsed)) {
return null;
}
const sessionId = parsed.session_id;
const result = parsed.result;
const subtype = parsed.subtype;
if (typeof sessionId !== "string" || typeof result !== "string" || typeof subtype !== "string") {
return null;
}
return {
type: typeof parsed.type === "string" ? parsed.type : "result",
subtype: subtype as ClaudeCodeParsedResult["subtype"],
result,
sessionId,
numTurns: typeof parsed.num_turns === "number" ? parsed.num_turns : 0,
totalCostUsd: typeof parsed.total_cost_usd === "number" ? parsed.total_cost_usd : 0,
durationMs: typeof parsed.duration_ms === "number" ? parsed.duration_ms : 0,
};
}
type ClaudeCodeSchemaHashes = {
detail: string;
rawOutput: string;
};
async function registerSchemas(store: Store): Promise<ClaudeCodeSchemaHashes> {
await bootstrap(store);
const [detail, rawOutput] = await Promise.all([
putSchema(store, CLAUDE_CODE_DETAIL_SCHEMA),
putSchema(store, CLAUDE_CODE_RAW_OUTPUT_SCHEMA),
]);
return { detail, rawOutput };
}
/** Store parsed Claude Code result as a CAS detail node. */
export async function storeClaudeCodeDetail(
store: Store,
parsed: ClaudeCodeParsedResult,
): Promise<{ detailHash: string; output: string; sessionId: string }> {
const schemas = await registerSchemas(store);
const detail: ClaudeCodeDetailPayload = {
sessionId: parsed.sessionId,
numTurns: parsed.numTurns,
totalCostUsd: parsed.totalCostUsd,
durationMs: parsed.durationMs,
subtype: parsed.subtype,
};
const detailHash = await store.put(schemas.detail, detail);
return { detailHash, output: parsed.result, sessionId: parsed.sessionId };
}
/** Fallback: store raw text output when JSON parsing fails. */
export async function storeClaudeCodeRawOutput(store: Store, rawOutput: string): Promise<string> {
const schemas = await registerSchemas(store);
return store.put(schemas.rawOutput, { text: rawOutput });
}
@@ -0,0 +1,19 @@
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget";
export type ClaudeCodeParsedResult = {
type: string;
subtype: ClaudeCodeResultSubtype;
result: string;
sessionId: string;
numTurns: number;
totalCostUsd: number;
durationMs: number;
};
export type ClaudeCodeDetailPayload = {
sessionId: string;
numTurns: number;
totalCostUsd: number;
durationMs: number;
subtype: string;
};
@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist" },
"include": ["src"],
"references": [{ "path": "../workflow-agent-kit" }]
}
+72 -25
View File
@@ -1,11 +1,16 @@
import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas";
import { type AgentContext, type AgentRunResult, buildRolePrompt, createAgent } from "@uncaged/workflow-agent-kit";
import {
type AgentContext,
type AgentRunResult,
buildRolePrompt,
createAgent,
} from "@uncaged/workflow-agent-kit";
import {
loadHermesSession,
parseSessionIdFromStdout,
storeHermesRawOutput,
storeHermesSessionDetail,
} from "./session-detail.js";
@@ -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,
});
}
@@ -2,13 +2,32 @@ 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).toContain("status: done");
expect(result).toContain("confidence:");
expect(result).toContain("scope: role");
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", () => {
@@ -16,17 +35,36 @@ describe("buildOutputFormatInstruction", () => {
expect(result).toContain("primary deliverable");
});
test("lists fields from a flat object schema", () => {
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`");
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", () => {
@@ -45,6 +83,8 @@ describe("buildOutputFormatInstruction", () => {
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", () => {
@@ -63,6 +103,8 @@ describe("buildOutputFormatInstruction", () => {
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", () => {
@@ -80,6 +122,45 @@ describe("buildOutputFormatInstruction", () => {
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", () => {
@@ -18,13 +18,16 @@ describe("buildRolePrompt", () => {
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", () => {
test("empty fields are omitted but Prepare is always present", () => {
const role: RoleDefinition = {
description: "A reviewer",
goal: "You are a code reviewer.",
@@ -35,12 +38,14 @@ describe("buildRolePrompt", () => {
};
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 returns empty string", () => {
test("all empty still includes Prepare section", () => {
const role: RoleDefinition = {
description: "Minimal",
goal: "",
@@ -50,7 +55,12 @@ describe("buildRolePrompt", () => {
meta: "placeholder00000" as string,
};
const result = buildRolePrompt(role);
expect(result).toBe("");
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", () => {
@@ -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,5 +1,11 @@
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.
*
@@ -9,9 +15,44 @@ import type { JSONSchema } from "@uncaged/json-cas";
*
* Returns an empty array for schemas with no inspectable property definitions.
*/
function extractSchemaFields(schema: JSONSchema): string[] {
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 Object.keys(schema.properties as Record<string, unknown>);
return [schema];
}
const unionKey = Array.isArray(schema.anyOf)
@@ -20,18 +61,109 @@ function extractSchemaFields(schema: JSONSchema): string[] {
? "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];
if (unionKey === null) {
return [];
}
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");
}
/**
@@ -42,28 +174,16 @@ function extractSchemaFields(schema: JSONSchema): string[] {
* 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)";
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:
\`\`\`
---
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 ...
${yamlExample}
\`\`\`
The frontmatter is the **primary deliverable** — the engine reads it directly.
@@ -71,5 +191,7 @@ 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.`;
}
@@ -1,10 +1,15 @@
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, Procedure, Output.
* 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[] = [];
@@ -18,6 +23,15 @@ export function buildRolePrompt(role: RoleDefinition): string {
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}`);
}
+4 -15
View File
@@ -6,8 +6,8 @@ import type {
StepNodePayload,
ThreadId,
} from "@uncaged/workflow-protocol";
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
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}`);
+143 -9
View File
@@ -1,13 +1,139 @@
import { validate } from "@uncaged/json-cas";
import type { Store } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef } from "@uncaged/workflow-protocol";
import { parseFrontmatterMarkdown, validateFrontmatter } from "@uncaged/workflow-util";
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.
*
@@ -32,16 +158,22 @@ export async function tryFrontmatterFastPath(
const validationErrors = validateFrontmatter(frontmatter);
if (validationErrors.length > 0) {
log(
"9GNPS4WY",
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
);
return null;
}
const candidate: Record<string, unknown> = {
status: frontmatter.status,
next: frontmatter.next,
confidence: frontmatter.confidence,
artifacts: [...frontmatter.artifacts],
scope: frontmatter.scope,
};
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"]>;
@@ -50,10 +182,12 @@ export async function tryFrontmatterFastPath(
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;
}
+9 -3
View File
@@ -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,10 +8,14 @@ export {
resolveExtractModelAlias,
resolveModel,
} from "./extract.js";
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
export { buildRolePrompt } from "./build-role-prompt.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";
+37 -34
View File
@@ -1,14 +1,14 @@
import { getSchema, validate } from "@uncaged/json-cas";
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 metaSchema = getSchema(ctx.meta.store, roleDef.meta);
if (metaSchema !== null) {
ctx.outputFormatInstruction = buildOutputFormatInstruction(metaSchema);
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.meta, storageRoot, ctx);
const stepHash = await persistStep({
ctx,
outputHash,
+8
View File
@@ -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;
};
@@ -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": {}
}
+400
View File
@@ -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 配置
```
+21
View File
@@ -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>
+38
View File
@@ -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"
}
}
+12
View File
@@ -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}`);
+78
View File
@@ -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;
};
+10
View File
@@ -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';
@@ -0,0 +1,27 @@
/**
* 便
*/
import { define } from "../context.tsx";
import { Injection } from '../injection.ts';
const NOOP = () => {};
const placeholder = new Injection(NOOP);
function make(): Injection {
return placeholder;
}
export const injection = define.view('injection', make, (set) => {
function reset() {
set(make());
}
function inject(instance: Injection) {
set(instance);
return reset;
}
return inject;
});
@@ -0,0 +1,50 @@
import { produce, type Draft } from 'immer';
import { applyNodeChanges, NodeChange } from '@xyflow/react';
import { define } from '../context';
import type { AnyWorkNode } from '../type';
function makeNodes(): AnyWorkNode[] {
return [
{
id: 'start',
type: 'start',
data: { label: 'Start' },
position: { x: 0, y: 0 },
},
{
id: 'end',
data: { label: 'End' },
position: { x: 1000, y: 0 },
type: 'end',
},
];
}
export const nodesModel = define.model('nodes', makeNodes, (set, _get, model) => {
const whites = new Set<NodeChange['type']>(['add', 'replace']);
function onNodesChange(changes: NodeChange<AnyWorkNode>[]) {
if (changes.some(c => whites.has(c.type))) {
model.startTransaction();
set((nds) => applyNodeChanges(changes, nds));
requestAnimationFrame(model.endTransaction);
return;
}
set((nds) => applyNodeChanges(changes, nds));
};
function editNode(id: string, updater: (node: Draft<AnyWorkNode>) => void) {
set(produce((draft) => {
const node = draft.find(n => n.id === id);
if (node) updater(node);
}));
}
function deleteNode(id: string) {
model.startTransaction();
set((nds) => nds.filter(n => n.id !== id));
requestAnimationFrame(model.endTransaction);
}
return { onNodesChange, set, editNode, deleteNode };
});
@@ -0,0 +1,23 @@
import { Handle, Position, Node, NodeProps } from '@xyflow/react';
import { EndNode } from './nodes.style';
interface NodeData {
label: string;
[key: string]: unknown;
}
type NodeType = Node<NodeData, 'end'>;
type Props = NodeProps<NodeType>;
export function NodeEnd({ data }: Props) {
return (
<EndNode>
<Handle
type="target"
position={Position.Left}
id="input"
/>
{data?.label || 'End'}
</EndNode>
);
}
@@ -0,0 +1,9 @@
import { NodeStart } from './start';
import { NodeEnd } from './end';
import { NodeRole } from './role';
export const nodeTypes = {
start: NodeStart,
end: NodeEnd,
role: NodeRole,
};
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "../../components/ui/button.tsx";
type Props = {
onEdit: (() => void) | undefined;
onDelete: (() => void) | undefined;
};
export function NodeToolbarActions({ onEdit, onDelete }: Props): ReactNode {
return (
<div className="flex gap-1 px-2 py-1 bg-white rounded-lg shadow-md border border-gray-200">
<Button variant="ghost" size="icon-xs" onClick={onEdit} title="编辑">
<Pencil />
</Button>
<Button variant="ghost" size="icon-xs" className="hover:bg-destructive/10 hover:text-destructive" onClick={onDelete} title="删除">
<Trash2 />
</Button>
</div>
);
}
@@ -0,0 +1,100 @@
import type { ReactNode } from "react";
import { cn } from "../../lib/utils.ts";
type Props = {
className: string | null;
children: ReactNode;
};
function BaseNode({ className, children }: Props): ReactNode {
return (
<div
className={cn(
"rounded-lg border-2 border-border bg-white px-4 py-3 text-center text-sm font-medium min-w-[120px]",
className,
)}
>
{children}
</div>
);
}
export function StartNode({ children }: { children: ReactNode }): ReactNode {
return (
<BaseNode className="bg-gradient-to-br from-green-50 to-green-200 border-green-500 text-green-500">
{children}
</BaseNode>
);
}
export function EndNode({ children }: { children: ReactNode }): ReactNode {
return (
<BaseNode className="bg-gradient-to-br from-indigo-50 to-blue-100 border-blue-600 text-blue-600">
{children}
</BaseNode>
);
}
export function NodeContent({ children }: { children: ReactNode }): ReactNode {
return (
<div className="flex items-start gap-2.5 px-3.5 py-3 min-w-[160px] max-w-[240px]">
{children}
</div>
);
}
export function NodeIcon({ className, children }: Props): ReactNode {
return (
<div
className={cn(
"flex items-center justify-center w-8 h-8 rounded-lg shrink-0",
className,
)}
>
{children}
</div>
);
}
export function NodeBody({ children }: { children: ReactNode }): ReactNode {
return <div className="flex-1 min-w-0">{children}</div>;
}
export function NodeKindLabel({ className, children }: Props): ReactNode {
return (
<div
className={cn(
"text-[10px] font-semibold uppercase tracking-wide mb-1",
className,
)}
>
{children}
</div>
);
}
export function NodeHint({ children }: { children: ReactNode }): ReactNode {
return (
<div className="text-[13px] text-gray-800 leading-snug break-words">
{children}
</div>
);
}
export function NodeSubHint({ children }: { children: ReactNode }): ReactNode {
return <div className="text-[11px] text-gray-400 mt-0.5">{children}</div>;
}
export function RoleIcon({ children }: { children: ReactNode }): ReactNode {
return (
<NodeIcon className="bg-gradient-to-br from-teal-50 to-teal-200 text-teal-700">
{children}
</NodeIcon>
);
}
export function RoleKindLabel({
children,
}: { children: ReactNode }): ReactNode {
return <NodeKindLabel className="text-teal-700">{children}</NodeKindLabel>;
}
@@ -0,0 +1,71 @@
import { Handle, Position, NodeToolbar, useNodeConnections, type NodeProps } from '@xyflow/react';
import { Users } from 'lucide-react';
import {
NodeContent,
NodeBody,
RoleIcon,
RoleKindLabel,
NodeHint,
} from './nodes.style';
import { NodeToolbarActions } from './node-toolbar';
import { editNodeViewModel } from '../model/edit-node-view';
import { nodesModel } from '../model';
import type { WorkNode } from '../type';
import { useMemo, type ReactNode } from 'react';
import { useReadonly } from '../flow';
type Props = NodeProps<WorkNode<'role'>>;
const containerClass = "bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150";
const targetClass = "!bg-blue-100 !border-blue-500 hover:!bg-blue-500 hover:!border-blue-600";
const sourceClass = "!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600";
export function NodeRole({ data, id, selected }: Props) {
const startEdit = editNodeViewModel.useCreation().start;
const { deleteNode } = nodesModel.useCreation();
const connections = useNodeConnections();
const readonly = useReadonly();
const connectedHandles = useMemo(() => {
const set = new Set<string>();
for (const c of connections) {
if (c.target === id && c.targetHandle) set.add(c.targetHandle);
if (c.source === id && c.sourceHandle) set.add(c.sourceHandle);
}
return set;
}, [connections, id]);
const hasInputConnection = connectedHandles.has('input') || connectedHandles.has('input-top') || connectedHandles.has('input-bottom');
const hasOutputConnection = connectedHandles.has('output') || connectedHandles.has('output-top') || connectedHandles.has('output-bottom');
const showHandle = (handleId: string, alwaysShow: boolean) => {
if (readonly) return connectedHandles.has(handleId);
return alwaysShow;
};
return (
<div className={containerClass}>
{showHandle('input', true) && <Handle type="target" position={Position.Left} id="input" className={targetClass} isConnectableStart />}
{showHandle('input-top', hasInputConnection) && <Handle type="target" position={Position.Top} id="input-top" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
{showHandle('input-bottom', hasInputConnection) && <Handle type="target" position={Position.Bottom} id="input-bottom" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
<NodeContent>
<RoleIcon>
<Users size={16} />
</RoleIcon>
<NodeBody>
<RoleKindLabel>Role</RoleKindLabel>
<NodeHint>{data.name}</NodeHint>
</NodeBody>
</NodeContent>
<NodeToolbar isVisible={selected && !readonly} position={Position.Bottom}>
<NodeToolbarActions
onEdit={() => startEdit(id)}
onDelete={() => deleteNode(id)}
/>
</NodeToolbar>
{showHandle('output', true) && <Handle type="source" position={Position.Right} id="output" className={sourceClass} isConnectableEnd />}
{showHandle('output-top', hasOutputConnection) && <Handle type="source" position={Position.Top} id="output-top" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
{showHandle('output-bottom', hasOutputConnection) && <Handle type="source" position={Position.Bottom} id="output-bottom" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
</div>
);
}
@@ -0,0 +1,31 @@
import { Handle, Position, Node, NodeProps, useNodeConnections } from '@xyflow/react';
import { StartNode } from './nodes.style';
import { useMemo } from 'react';
interface NodeData {
label: string;
[key: string]: unknown;
}
type NodeType = Node<NodeData, 'start'>;
type Props = NodeProps<NodeType>;
export function NodeStart({ data, id }: Props) {
const connections = useNodeConnections();
const outputConnected = useMemo(() => {
return connections.some((conn) => conn.source === id);
}, [connections, id]);
return (
<StartNode>
{data?.label || 'Start'}
<Handle
type="source"
position={Position.Right}
id="output"
isConnectable={!outputConnected}
/>
</StartNode>
);
}
@@ -0,0 +1,146 @@
import { useState, useEffect, type ReactNode } from "react";
import { addNodeViewModel, type AddNodeState } from "../model/index.ts";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../../components/ui/dialog.tsx";
import { Input } from "../../components/ui/input.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { Button } from "../../components/ui/button.tsx";
import { Label } from "../../components/ui/label.tsx";
import type { RoleNodeData } from "../type.ts";
type FormProps = {
state: AddNodeState;
onSubmit: (params: { data: RoleNodeData }) => void;
onCancel: () => void;
};
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
const [name, setName] = useState("新角色");
const [description, setDescription] = useState("");
const [identity, setIdentity] = useState("");
const [prepare, setPrepare] = useState("");
const [execute, setExecute] = useState("");
const [report, setReport] = useState("");
useEffect(() => {
setName("新角色");
setDescription("");
setIdentity("");
setPrepare("");
setExecute("");
setReport("");
}, [state]);
function handleConfirm() {
if (!name.trim()) return;
onSubmit({
data: {
name: name.trim(),
description,
identity,
prepare,
execute,
report,
},
});
}
return (
<>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> *</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="角色名称"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Textarea
rows={2}
className="resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="角色描述"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Identity)</Label>
<Textarea
rows={2}
className="resize-none"
value={identity}
onChange={(e) => setIdentity(e.target.value)}
placeholder="角色身份定义"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Prepare)</Label>
<Textarea
rows={2}
className="resize-none"
value={prepare}
onChange={(e) => setPrepare(e.target.value)}
placeholder="执行前准备指令"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Execute)</Label>
<Textarea
rows={2}
className="resize-none"
value={execute}
onChange={(e) => setExecute(e.target.value)}
placeholder="核心执行指令"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Report)</Label>
<Textarea
rows={2}
className="resize-none"
value={report}
onChange={(e) => setReport(e.target.value)}
placeholder="输出格式指令"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onCancel}>
</Button>
<Button size="sm" onClick={handleConfirm}>
</Button>
</DialogFooter>
</>
);
}
export function AddNodeDialog(): ReactNode {
const state = addNodeViewModel.useData();
const { commit, cancel } = addNodeViewModel.useCreation();
return (
<Dialog
open={state !== null}
onOpenChange={(open) => { if (!open) cancel(); }}
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,148 @@
import { useState, useEffect, type ReactNode } from "react";
import {
editNodeViewModel,
type EditNodeState,
} from "../model/edit-node-view.ts";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../../components/ui/dialog.tsx";
import { Input } from "../../components/ui/input.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { Button } from "../../components/ui/button.tsx";
import { Label } from "../../components/ui/label.tsx";
import type { RoleNodeData } from "../type.ts";
type FormProps = {
state: EditNodeState;
onSubmit: (data: RoleNodeData) => void;
onCancel: () => void;
};
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
const data = state.node.data;
const [name, setName] = useState(data.name);
const [description, setDescription] = useState(data.description);
const [identity, setIdentity] = useState(data.identity);
const [prepare, setPrepare] = useState(data.prepare);
const [execute, setExecute] = useState(data.execute);
const [report, setReport] = useState(data.report);
useEffect(() => {
setName(data.name);
setDescription(data.description);
setIdentity(data.identity);
setPrepare(data.prepare);
setExecute(data.execute);
setReport(data.report);
}, [data]);
function handleConfirm() {
if (!name.trim()) return;
onSubmit({
name: name.trim(),
description,
identity,
prepare,
execute,
report,
});
}
return (
<>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> *</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="角色名称"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Textarea
rows={2}
className="resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="角色描述"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Identity)</Label>
<Textarea
rows={2}
className="resize-none"
value={identity}
onChange={(e) => setIdentity(e.target.value)}
placeholder="角色身份定义"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Prepare)</Label>
<Textarea
rows={2}
className="resize-none"
value={prepare}
onChange={(e) => setPrepare(e.target.value)}
placeholder="执行前准备指令"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Execute)</Label>
<Textarea
rows={2}
className="resize-none"
value={execute}
onChange={(e) => setExecute(e.target.value)}
placeholder="核心执行指令"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> (Report)</Label>
<Textarea
rows={2}
className="resize-none"
value={report}
onChange={(e) => setReport(e.target.value)}
placeholder="输出格式指令"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onCancel}>
</Button>
<Button size="sm" onClick={handleConfirm}>
</Button>
</DialogFooter>
</>
);
}
export function EditNodeDialog(): ReactNode {
const state = editNodeViewModel.useData();
const { commit, cancel } = editNodeViewModel.useCreation();
return (
<Dialog
open={state !== null}
onOpenChange={(open) => { if (!open) cancel(); }}
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,23 @@
import { Panel } from '@xyflow/react';
import { AddNodeDialog } from './add-node';
import { EditNodeDialog } from './edit-node';
import { Toolbar } from './toolbar';
export function Dialogs() {
return (
<>
<AddNodeDialog />
<EditNodeDialog />
</>
);
}
export function TopCenterPanel() {
return (
<Panel position="top-center">
<Toolbar />
</Panel>
);
}
@@ -0,0 +1,138 @@
import { type ReactNode } from "react";
import {
Undo2,
Redo2,
Users,
LayoutList,
Save,
} from "lucide-react";
import { useReactFlow, useStoreApi } from "@xyflow/react";
import { useModel } from "../context.tsx";
import { handlers, nodesModel } from "../model/index.ts";
import { Separator } from "../../components/ui/separator.tsx";
import { Button } from "../../components/ui/button.tsx";
import type { RoleNodeData, WorkNode } from "../type.ts";
import { uuid } from "../utils/index.ts";
import { useState } from "react";
import { cn } from "../../lib/utils.ts";
const DEFAULT_ROLE_DATA: RoleNodeData = {
name: '新角色',
description: '',
identity: '',
prepare: '',
execute: '',
report: '',
};
export function Toolbar(): ReactNode {
const model = useModel();
const flow = useReactFlow();
const store = useStoreApi();
const nodesActions = nodesModel.useCreation();
const { autoLayoutLR } = handlers.use();
const [canUndo, canRedo] = model.useStackState();
function handleUndo() {
model.undo();
}
function handleRedo() {
model.redo();
}
function handleAddNode() {
const { x, y, zoom } = flow.getViewport();
const { width, height } = store.getState();
const centerX = (width / 2 - x) / zoom;
const centerY = (height / 2 - y) / zoom;
const id = `n${uuid()}`;
const node: WorkNode<'role'> = {
id,
type: 'role',
position: { x: centerX - 80, y: centerY - 40 },
data: { ...DEFAULT_ROLE_DATA },
};
model.startTransaction();
nodesActions.set((nds) => nds.concat(node));
requestAnimationFrame(model.endTransaction);
}
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-[10px] shadow-md">
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon-sm" title="撤销 (Undo)" onClick={handleUndo} disabled={!canUndo}>
<Undo2 />
</Button>
<Button variant="ghost" size="icon-sm" title="重做 (Redo)" onClick={handleRedo} disabled={!canRedo}>
<Redo2 />
</Button>
</div>
<Separator orientation="vertical" className="h-6" />
<Button variant="ghost" size="icon-sm" title="添加角色" onClick={handleAddNode}>
<Users />
</Button>
<Separator orientation="vertical" className="h-6" />
<Button variant="ghost" size="icon-sm" title="自动布局" onClick={autoLayoutLR}>
<LayoutList />
</Button>
<SaveButton />
</div>
);
}
function SaveButton(): ReactNode {
const { saveData } = handlers.use();
const [toast, setToast] = useState<{
open: boolean;
severity: "success" | "error";
message: ReactNode;
}>({ open: false, severity: "success", message: "" });
function handleSave() {
const { valid, errors } = saveData();
if (valid) {
setToast({ open: true, severity: "success", message: "流程保存成功" });
} else {
const errorMessages = errors.map(
({ message, nodeId }) => (
<div key={nodeId ?? message}>
{nodeId ? `节点 ${nodeId}` : ""}
{message}
</div>
),
);
setToast({
open: true,
severity: "error",
message: errorMessages || "流程校验失败",
});
}
setTimeout(() => setToast((prev) => ({ ...prev, open: false })), 4000);
}
return (
<>
<Button variant="ghost" size="icon-sm" title="保存流程" onClick={handleSave}>
<Save />
</Button>
{toast.open && (
<div
className={cn(
"fixed top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg text-sm text-white shadow-lg",
toast.severity === "success" ? "bg-green-600" : "bg-red-600",
)}
>
{toast.message}
</div>
)}
</>
);
}
@@ -0,0 +1,4 @@
export * from './type';
export * from './trans-in';
export * from './trans-out';
export * from './validate';
@@ -0,0 +1,156 @@
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
import type { WorkFlowStep } from './type';
import { uuid } from '../utils';
type Result = {
nodes: AnyWorkNode[];
edges: AnyWorkEdge[];
};
const OUT_HANDLES = ['output-top', 'output', 'output-bottom'] as const;
const IN_HANDLES = ['input-top', 'input', 'input-bottom'] as const;
function assignHandles(
indices: number[],
edges: AnyWorkEdge[],
handles: readonly string[],
key: 'sourceHandle' | 'targetHandle',
): void {
if (indices.length === 1) {
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
} else if (indices.length === 2) {
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
edges[indices[1]] = { ...edges[indices[1]], [key]: handles[0] };
} else {
for (let i = 0; i < indices.length; i++) {
edges[indices[i]] = { ...edges[indices[i]], [key]: handles[i % handles.length] };
}
}
}
export function transIn(steps: WorkFlowStep[]): Result {
const startNode: AnyWorkNode = { id: 'start', type: 'start', data: { label: 'Start' }, position: { x: 0, y: 0 } };
const endNode: AnyWorkNode = { id: 'end', type: 'end', data: { label: 'End' }, position: { x: 250, y: 0 } };
if (steps.length === 0) {
return { nodes: [startNode, endNode], edges: [] };
}
const nodes: AnyWorkNode[] = [startNode, endNode];
const edges: AnyWorkEdge[] = [];
const nameToId = new Map<string, string>();
const idToOrder = new Map<string, number>();
nameToId.set('END', 'end');
idToOrder.set('start', -1);
idToOrder.set('end', steps.length);
for (let si = 0; si < steps.length; si++) {
const step = steps[si];
const nodeId = `n${uuid()}`;
nameToId.set(step.role.name, nodeId);
idToOrder.set(nodeId, si);
nodes.push({
id: nodeId,
type: 'role',
data: { ...step.role },
position: { x: 0, y: 0 },
});
}
const firstStepId = nameToId.get(steps[0].role.name)!;
edges.push({
id: `e-start-${firstStepId}`,
source: 'start',
sourceHandle: 'output',
target: firstStepId,
targetHandle: 'input',
animated: true,
});
for (const step of steps) {
const sourceId = nameToId.get(step.role.name)!;
const sourceOrder = idToOrder.get(sourceId)!;
const hasMultipleTransitions = step.transitions.length > 1;
const sorted = hasMultipleTransitions
? [...step.transitions].sort((a, b) => {
if (a.condition === null && b.condition !== null) return -1;
if (a.condition !== null && b.condition === null) return 1;
return 0;
})
: step.transitions;
const elseEdges: AnyWorkEdge[] = [];
const ifEdges: AnyWorkEdge[] = [];
for (let i = 0; i < sorted.length; i++) {
const t = sorted[i];
const targetId = nameToId.get(t.target);
if (!targetId) continue;
const edgeId = `e-${sourceId}-${targetId}-${i}`;
if (hasMultipleTransitions || t.condition !== null) {
const edge: ConditionalEdge = {
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: 'output',
targetHandle: 'input',
type: 'conditional',
data: { condition: t.condition ?? '' },
animated: true,
};
if (hasMultipleTransitions && i === 0) {
elseEdges.push(edge);
} else {
ifEdges.push(edge);
}
} else {
elseEdges.push({
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: 'output',
targetHandle: 'input',
animated: true,
});
}
}
// out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom
for (const e of elseEdges) {
edges.push({ ...e, sourceHandle: 'output' });
}
if (ifEdges.length > 0) {
const sortedIf = [...ifEdges].sort((a, b) => {
const oa = idToOrder.get(a.target) ?? 0;
const ob = idToOrder.get(b.target) ?? 0;
return ob - oa;
});
const ifHandles = ['output-top', 'output-bottom'] as const;
for (let i = 0; i < sortedIf.length; i++) {
edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] });
}
}
}
// in: group by target, sort by source order asc (leftmost first), assign input > input-top > input-bottom
const incomingByTarget = new Map<string, number[]>();
for (let i = 0; i < edges.length; i++) {
const target = edges[i].target;
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
incomingByTarget.get(target)!.push(i);
}
for (const indices of incomingByTarget.values()) {
indices.sort((a, b) => {
const oa = idToOrder.get(edges[a].source) ?? 0;
const ob = idToOrder.get(edges[b].source) ?? 0;
return oa - ob;
});
assignHandles(indices, edges, IN_HANDLES, 'targetHandle');
}
return { nodes, edges };
}
@@ -0,0 +1,70 @@
import type { AnyWorkNode, AnyWorkEdge, WorkNode, ConditionalEdge } from '../type';
import type { WorkFlowStep, WorkFlowTransition } from './type';
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
const nodeMap = new Map<string, AnyWorkNode>();
for (const node of nodes) {
nodeMap.set(node.id, node);
}
const outgoingEdges = new Map<string, AnyWorkEdge[]>();
for (const edge of edges) {
if (!outgoingEdges.has(edge.source)) {
outgoingEdges.set(edge.source, []);
}
outgoingEdges.get(edge.source)!.push(edge);
}
const startOutEdges = outgoingEdges.get('start') ?? [];
if (startOutEdges.length === 0) return [];
const firstNodeId = startOutEdges[0].target;
const visited = new Set<string>();
const steps: WorkFlowStep[] = [];
traverse(firstNodeId, nodeMap, outgoingEdges, visited, steps);
return steps;
}
function traverse(
nodeId: string,
nodeMap: Map<string, AnyWorkNode>,
outgoingEdges: Map<string, AnyWorkEdge[]>,
visited: Set<string>,
steps: WorkFlowStep[],
): void {
if (visited.has(nodeId) || nodeId === 'start' || nodeId === 'end') return;
visited.add(nodeId);
const node = nodeMap.get(nodeId);
if (!node || node.type !== 'role') return;
const roleNode = node as WorkNode<'role'>;
const outEdges = outgoingEdges.get(nodeId) ?? [];
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
const targetNode = nodeMap.get(edge.target);
const target = edge.target === 'end'
? 'END'
: (targetNode?.type === 'role' ? (targetNode as WorkNode<'role'>).data.name : edge.target);
let condition: string | null = null;
if (edge.type === 'conditional') {
const isElse = outEdges.length >= 2 && index === 0;
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
}
return { target, condition };
});
const { name, description, identity, prepare, execute, report } = roleNode.data;
steps.push({
role: { name, description, identity, prepare, execute, report },
transitions,
});
for (const edge of outEdges) {
traverse(edge.target, nodeMap, outgoingEdges, visited, steps);
}
}
@@ -0,0 +1,6 @@
export type {
WorkFlowRole,
WorkFlowTransition,
WorkFlowStep,
WorkFlowSteps,
} from "../../../shared/types.ts";
@@ -0,0 +1,187 @@
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
export type ValidationError = {
nodeId: string | null;
message: string;
};
export type ValidationResult = {
valid: boolean;
errors: ValidationError[];
};
export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): ValidationResult {
const errors: ValidationError[] = [];
const outgoing = buildEdgeMap(edges, 'source');
const incoming = buildEdgeMap(edges, 'target');
const startNodes = nodes.filter(n => n.type === 'start');
const endNodes = nodes.filter(n => n.type === 'end');
const roleNodes = nodes.filter(n => n.type === 'role');
validateStartNode(startNodes, outgoing, errors);
validateEndNode(endNodes, incoming, outgoing, errors);
validateRoleNodes(roleNodes, outgoing, incoming, errors);
validateRoleCount(roleNodes, errors);
validateReachability(nodes, edges, startNodes, endNodes, errors);
return { valid: errors.length === 0, errors };
}
function buildEdgeMap(
edges: AnyWorkEdge[],
key: 'source' | 'target',
): Map<string, AnyWorkEdge[]> {
const map = new Map<string, AnyWorkEdge[]>();
for (const edge of edges) {
const id = edge[key];
if (!map.has(id)) {
map.set(id, []);
}
map.get(id)!.push(edge);
}
return map;
}
function validateStartNode(
startNodes: AnyWorkNode[],
outgoing: Map<string, AnyWorkEdge[]>,
errors: ValidationError[],
): void {
if (startNodes.length === 0) {
errors.push({ nodeId: null, message: '缺少 Start 节点' });
return;
}
if (startNodes.length > 1) {
errors.push({ nodeId: null, message: 'Start 节点只能有一个' });
return;
}
const startId = startNodes[0].id;
const outEdges = outgoing.get(startId) ?? [];
if (outEdges.length === 0) {
errors.push({ nodeId: startId, message: 'Start 节点必须有一个输出连接' });
} else if (outEdges.length > 1) {
errors.push({ nodeId: startId, message: 'Start 节点只能有一个输出连接' });
}
}
function validateEndNode(
endNodes: AnyWorkNode[],
incoming: Map<string, AnyWorkEdge[]>,
outgoing: Map<string, AnyWorkEdge[]>,
errors: ValidationError[],
): void {
if (endNodes.length === 0) {
errors.push({ nodeId: null, message: '缺少 End 节点' });
return;
}
if (endNodes.length > 1) {
errors.push({ nodeId: null, message: 'End 节点只能有一个' });
return;
}
const endId = endNodes[0].id;
const inEdges = incoming.get(endId) ?? [];
if (inEdges.length === 0) {
errors.push({ nodeId: endId, message: 'End 节点必须有至少一个输入连接' });
}
const outEdges = outgoing.get(endId) ?? [];
if (outEdges.length > 0) {
errors.push({ nodeId: endId, message: 'End 节点不能有输出连接' });
}
}
function validateRoleNodes(
roleNodes: AnyWorkNode[],
outgoing: Map<string, AnyWorkEdge[]>,
incoming: Map<string, AnyWorkEdge[]>,
errors: ValidationError[],
): void {
for (const node of roleNodes) {
const inEdges = incoming.get(node.id) ?? [];
const outEdges = outgoing.get(node.id) ?? [];
if (inEdges.length === 0) {
errors.push({ nodeId: node.id, message: '角色节点缺少输入连接' });
}
if (outEdges.length === 0) {
errors.push({ nodeId: node.id, message: '角色节点缺少输出连接' });
}
if (outEdges.length > 1) {
const conditionalEdges = outEdges.filter(e => e.type === 'conditional');
if (conditionalEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: '多输出节点的所有出边必须附带条件' });
} else {
const ifEdges = conditionalEdges.slice(1);
for (const edge of ifEdges) {
const condEdge = edge as ConditionalEdge;
if (!condEdge.data?.condition?.trim()) {
errors.push({ nodeId: node.id, message: '条件边的条件表达式不能为空' });
break;
}
}
}
}
}
}
function validateRoleCount(
roleNodes: AnyWorkNode[],
errors: ValidationError[],
): void {
if (roleNodes.length < 2) {
errors.push({ nodeId: null, message: '工作流至少需要 2 个角色节点' });
}
}
function validateReachability(
nodes: AnyWorkNode[],
edges: AnyWorkEdge[],
startNodes: AnyWorkNode[],
endNodes: AnyWorkNode[],
errors: ValidationError[],
): void {
if (startNodes.length !== 1 || endNodes.length !== 1) return;
const forwardAdj = new Map<string, string[]>();
const backwardAdj = new Map<string, string[]>();
for (const edge of edges) {
if (!forwardAdj.has(edge.source)) forwardAdj.set(edge.source, []);
forwardAdj.get(edge.source)!.push(edge.target);
if (!backwardAdj.has(edge.target)) backwardAdj.set(edge.target, []);
backwardAdj.get(edge.target)!.push(edge.source);
}
const reachableFromStart = bfs(startNodes[0].id, forwardAdj);
const reachableFromEnd = bfs(endNodes[0].id, backwardAdj);
for (const node of nodes) {
if (node.type === 'start' || node.type === 'end') continue;
if (!reachableFromStart.has(node.id)) {
errors.push({ nodeId: node.id, message: '节点不可从 Start 到达(孤立节点)' });
}
if (!reachableFromEnd.has(node.id)) {
errors.push({ nodeId: node.id, message: '节点无法到达 End(死端节点)' });
}
}
}
function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
const visited = new Set<string>();
const queue = [startId];
visited.add(startId);
while (queue.length > 0) {
const current = queue.shift()!;
for (const next of adj.get(current) ?? []) {
if (!visited.has(next)) {
visited.add(next);
queue.push(next);
}
}
}
return visited;
}
@@ -0,0 +1,29 @@
import type { Node, Edge } from '@xyflow/react';
type AnyKeyBase = { [key: string]: unknown | undefined };
export type RoleNodeData = AnyKeyBase & {
name: string;
description: string;
identity: string;
prepare: string;
execute: string;
report: string;
};
export type NodeMap = {
start: { label: string };
end: { label: string };
role: RoleNodeData;
};
export type WorkNodeType = keyof NodeMap;
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
export type AnyWorkNode = WorkNode<'start'> | WorkNode<'end'> | WorkNode<'role'>;
export type ConditionalEdgeData = AnyKeyBase & {
condition: string;
};
export type ConditionalEdge = Edge<ConditionalEdgeData, 'conditional'>;
export type AnyWorkEdge = ConditionalEdge | Edge;
@@ -0,0 +1,31 @@
interface Maper<T> { [key: string]: T }
type Listen<T> = (data: T) => void;
export class Eventer<M extends Maper<any>> {
private lisenters = {} as { [K in keyof M]: Set<Function> };
public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) {
let set = this.lisenters[key];
if (set == undefined) {
set = new Set();
this.lisenters[key] = set;
}
set.add(lisenter);
return () => this.off(key, lisenter);
}
public off<K extends keyof M>(key: K, lisenter?: Listen<M[K]>) {
const set = this.lisenters[key];
if (set === undefined) return;
if (lisenter === undefined) set.clear();
else set.delete(lisenter);
}
public emit<K extends keyof M>(key: K, data: M[K]) {
const set = this.lisenters[key];
if (set === undefined) return;
// Todo: maybe implement stoping bubble
set.forEach(call => call(data));
}
}
@@ -0,0 +1,7 @@
export function uuid() {
const now = Date.now();
const randon = 1 + Math.random();
return Math.round(now * randon).toString(36);
}

Some files were not shown because too many files have changed in this diff Show More