Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1bc01ed85 | |||
| d3b5d66208 |
@@ -1,8 +0,0 @@
|
|||||||
# Changesets
|
|
||||||
|
|
||||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
|
||||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
|
||||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
|
|
||||||
|
|
||||||
We have a quick list of common questions to get you started engaging with this project in
|
|
||||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
|
||||||
"changelog": "@changesets/cli/changelog",
|
|
||||||
"commit": false,
|
|
||||||
"fixed": [["@uncaged/*"]],
|
|
||||||
"linked": [],
|
|
||||||
"access": "public",
|
|
||||||
"baseBranch": "main",
|
|
||||||
"updateInternalDependencies": "patch",
|
|
||||||
"ignore": ["@uncaged/workflow-dashboard"]
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,27 @@
|
|||||||
# No Dynamic Import
|
---
|
||||||
|
description: Ban dynamic import() in production code — use static imports instead
|
||||||
|
globs: packages/*/src/**/*.ts
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
See [docs/no-dynamic-import.md](../../docs/no-dynamic-import.md) for full rules.
|
# No Dynamic Import in Production Code
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
|
||||||
|
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||||
|
Always use static top-level `import` statements.
|
||||||
|
|
||||||
|
## Exception (must include a comment explaining why)
|
||||||
|
|
||||||
|
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
||||||
|
|
||||||
|
When suppressing, add a comment directly above:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Dynamic import required: user bundle path resolved at runtime
|
||||||
|
const mod = await import(bundlePath);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
Test files (`__tests__/**`) are exempt.
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# Sync Readme
|
|
||||||
|
|
||||||
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['*']
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Check
|
|
||||||
run: bun run check
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: bun run test:ci
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "🔍 Running check (tsc + biome + lint-log-tags)..."
|
|
||||||
bun run check
|
|
||||||
|
|
||||||
echo "🧪 Running tests..."
|
|
||||||
bun run test
|
|
||||||
|
|
||||||
echo "✅ All checks passed!"
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug Report
|
|
||||||
about: Report a bug or unexpected behavior
|
|
||||||
labels: bug
|
|
||||||
---
|
|
||||||
|
|
||||||
## Describe the bug
|
|
||||||
|
|
||||||
A clear description of what the bug is.
|
|
||||||
|
|
||||||
## To reproduce
|
|
||||||
|
|
||||||
Steps or commands to reproduce:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uwf ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Expected behavior
|
|
||||||
|
|
||||||
What you expected to happen.
|
|
||||||
|
|
||||||
## Actual behavior
|
|
||||||
|
|
||||||
What actually happened. Include error messages or logs.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
- OS:
|
|
||||||
- Bun version:
|
|
||||||
- uwf version (`uwf --version`):
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature Request
|
|
||||||
about: Suggest a new feature or improvement
|
|
||||||
labels: enhancement
|
|
||||||
---
|
|
||||||
|
|
||||||
## What
|
|
||||||
|
|
||||||
Describe the feature or improvement.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Why is this needed? What problem does it solve?
|
|
||||||
|
|
||||||
## Proposed solution
|
|
||||||
|
|
||||||
How should it work? Include API sketches, CLI examples, or workflow YAML snippets if applicable.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
## What
|
|
||||||
|
|
||||||
What this PR does.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Why the change is needed.
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
- `path/to/file` — what changed and why
|
|
||||||
|
|
||||||
## Ref
|
|
||||||
|
|
||||||
Fixes #
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- run: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: bunx biome check .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: bun run test:ci
|
|
||||||
@@ -6,11 +6,3 @@ tsconfig.tsbuildinfo
|
|||||||
.npmrc
|
.npmrc
|
||||||
|
|
||||||
bunfig.toml
|
bunfig.toml
|
||||||
xiaoju/
|
|
||||||
solve-issue-entry.ts
|
|
||||||
packages/workflow-template-develop/develop.esm.js
|
|
||||||
.DS_Store
|
|
||||||
*.py
|
|
||||||
.claude
|
|
||||||
tmp.worktrees/
|
|
||||||
.worktrees/
|
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
name: "e2e-walkthrough"
|
|
||||||
description: "End-to-end walkthrough of uwf CLI. Dogfooding: uwf tests uwf. Each role validates a phase of the CLI surface inside an isolated Docker container."
|
|
||||||
roles:
|
|
||||||
bootstrap:
|
|
||||||
description: "Start Docker container with isolated storage, verify uwf is runnable"
|
|
||||||
goal: "You are an E2E test runner. Set up an isolated Docker environment and verify basic uwf functionality."
|
|
||||||
capabilities:
|
|
||||||
- docker
|
|
||||||
- shell
|
|
||||||
procedure: |
|
|
||||||
1. Start a Docker container with isolated storage:
|
|
||||||
```
|
|
||||||
docker run -d --name uwf-e2e-$$ \
|
|
||||||
-v $HOME:$HOME \
|
|
||||||
-e HOME=$HOME \
|
|
||||||
-e UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage \
|
|
||||||
-w ~/repos/workflow \
|
|
||||||
node:22-bookworm \
|
|
||||||
sleep infinity
|
|
||||||
```
|
|
||||||
2. Inside the container, install bun, install deps, then `bun link` all packages
|
|
||||||
so that `uwf`, `uwf-hermes`, `uwf-builtin` are on PATH (from source):
|
|
||||||
```
|
|
||||||
docker exec uwf-e2e-$$ bash -c '
|
|
||||||
# Install bun
|
|
||||||
curl -fsSL https://bun.sh/install | bash
|
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
|
||||||
|
|
||||||
# Isolated storage
|
|
||||||
mkdir -p $UNCAGED_WORKFLOW_STORAGE_ROOT
|
|
||||||
|
|
||||||
# Install workspace deps
|
|
||||||
cd ~/repos/workflow && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# bun link each package that has a bin entry
|
|
||||||
cd packages/cli-workflow && bun link && cd ../..
|
|
||||||
cd packages/workflow-agent-hermes && bun link && cd ../..
|
|
||||||
cd packages/workflow-agent-builtin && bun link && cd ../..
|
|
||||||
'
|
|
||||||
```
|
|
||||||
3. Verify all three commands are available inside the container:
|
|
||||||
```
|
|
||||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf --version'
|
|
||||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-hermes --help'
|
|
||||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-builtin --help'
|
|
||||||
```
|
|
||||||
4. Copy host config if it exists:
|
|
||||||
```
|
|
||||||
docker exec uwf-e2e-$$ bash -c '
|
|
||||||
if [ -f $HOME/.uncaged/workflow/config.yaml ]; then
|
|
||||||
cp $HOME/.uncaged/workflow/config.yaml $UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml
|
|
||||||
fi
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Report the container name and confirm uwf + agents are working.
|
|
||||||
Set containerName to the Docker container name for subsequent roles.
|
|
||||||
output: "Report uwf version and container readiness. Set $status to pass with containerName, or fail with error."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "pass" }
|
|
||||||
containerName: { type: string }
|
|
||||||
required: [$status, containerName]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fail" }
|
|
||||||
error: { type: string }
|
|
||||||
required: [$status, error]
|
|
||||||
|
|
||||||
config-and-registry:
|
|
||||||
description: "Validate uwf config commands and workflow registration"
|
|
||||||
goal: "You are an E2E test runner. Validate uwf config operations and workflow registration inside the Docker container."
|
|
||||||
capabilities:
|
|
||||||
- docker
|
|
||||||
- shell
|
|
||||||
procedure: |
|
|
||||||
Use the container from the previous step (containerName is in your prompt).
|
|
||||||
All commands run via: `docker exec <containerName> bash -c '...'`
|
|
||||||
All commands use `uwf` (installed via `bun link` inside the container).
|
|
||||||
Remember to set env vars in each exec:
|
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
|
||||||
export UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
|
||||||
|
|
||||||
Config tests:
|
|
||||||
1. `uwf config list` — verify it returns valid JSON
|
|
||||||
2. `uwf config set models.test.name test-model` — set a test key
|
|
||||||
3. `uwf config get models.test.name` — verify it returns "test-model"
|
|
||||||
|
|
||||||
Workflow registration tests:
|
|
||||||
4. `uwf workflow add ~/repos/workflow/examples/solve-issue.yaml` — register workflow
|
|
||||||
5. Verify the output contains a hash
|
|
||||||
6. `uwf workflow list` — verify non-empty array
|
|
||||||
7. Capture the workflow name from the list
|
|
||||||
8. `uwf workflow show <name>` — verify it returns roles
|
|
||||||
|
|
||||||
Report all test results with pass/fail counts.
|
|
||||||
output: "Report test results. Set $status to pass (with workflowName and containerName) or fail."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "pass" }
|
|
||||||
workflowName: { type: string }
|
|
||||||
containerName: { type: string }
|
|
||||||
required: [$status, workflowName, containerName]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fail" }
|
|
||||||
error: { type: string }
|
|
||||||
containerName: { type: string }
|
|
||||||
required: [$status, error, containerName]
|
|
||||||
|
|
||||||
thread-ops:
|
|
||||||
description: "Test thread start, list, show, and exec"
|
|
||||||
goal: "You are an E2E test runner. Validate thread creation and execution inside the Docker container."
|
|
||||||
capabilities:
|
|
||||||
- docker
|
|
||||||
- shell
|
|
||||||
procedure: |
|
|
||||||
Use the container (containerName) and workflow (workflowName) from your prompt.
|
|
||||||
All commands via: `docker exec <containerName> bash -c '...'`
|
|
||||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
|
||||||
|
|
||||||
1. `uwf thread start <workflowName> -p 'E2E test: what is 2+2?'` — capture thread ID from JSON output
|
|
||||||
2. `uwf thread list` — verify the thread appears in the list
|
|
||||||
3. `uwf thread show <threadId>` — verify head pointer exists
|
|
||||||
4. `uwf thread exec <threadId> --agent uwf-builtin` — execute one step
|
|
||||||
5. Verify exec returns JSON with a head field
|
|
||||||
|
|
||||||
Report results. Pass threadId and containerName forward.
|
|
||||||
output: "Report test results. Set $status to pass (with threadId, workflowName, containerName) or fail."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "pass" }
|
|
||||||
threadId: { type: string }
|
|
||||||
workflowName: { type: string }
|
|
||||||
containerName: { type: string }
|
|
||||||
required: [$status, threadId, workflowName, containerName]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fail" }
|
|
||||||
error: { type: string }
|
|
||||||
containerName: { type: string }
|
|
||||||
required: [$status, error, containerName]
|
|
||||||
|
|
||||||
inspect:
|
|
||||||
description: "Test step list/show, thread read, and CAS operations"
|
|
||||||
goal: "You are an E2E test runner. Validate read and inspect operations inside the Docker container."
|
|
||||||
capabilities:
|
|
||||||
- docker
|
|
||||||
- shell
|
|
||||||
procedure: |
|
|
||||||
Use the container (containerName) and threadId from your prompt.
|
|
||||||
All commands via: `docker exec <containerName> bash -c '...'`
|
|
||||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
|
||||||
|
|
||||||
Step inspection:
|
|
||||||
1. `uwf step list <threadId>` — verify steps array has length > 1
|
|
||||||
2. Capture the last step hash from the output
|
|
||||||
3. `uwf step show <lastStepHash>` — verify it returns a role field
|
|
||||||
|
|
||||||
Thread read:
|
|
||||||
4. `uwf thread read <threadId>` — verify non-empty output
|
|
||||||
|
|
||||||
CAS operations:
|
|
||||||
5. `uwf cas get <lastStepHash>` — verify returns a type field
|
|
||||||
6. `uwf cas has <lastStepHash>` — verify exits 0
|
|
||||||
7. `uwf cas refs <lastStepHash>` — list refs (may be empty)
|
|
||||||
8. `uwf cas walk <lastStepHash>` — verify returns non-empty array
|
|
||||||
|
|
||||||
Report results. Pass threadId, lastStepHash, workflowName, containerName forward.
|
|
||||||
output: "Report test results. Set $status to pass (with threadId, lastStepHash, workflowName, containerName) or fail."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "pass" }
|
|
||||||
threadId: { type: string }
|
|
||||||
lastStepHash: { type: string }
|
|
||||||
workflowName: { type: string }
|
|
||||||
containerName: { type: string }
|
|
||||||
required: [$status, threadId, lastStepHash, workflowName, containerName]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fail" }
|
|
||||||
error: { type: string }
|
|
||||||
containerName: { type: string }
|
|
||||||
required: [$status, error, containerName]
|
|
||||||
|
|
||||||
cancel-and-fork:
|
|
||||||
description: "Test thread cancel, step fork, and log inspection"
|
|
||||||
goal: "You are an E2E test runner. Validate cancel, fork, and log operations inside the Docker container."
|
|
||||||
capabilities:
|
|
||||||
- docker
|
|
||||||
- shell
|
|
||||||
procedure: |
|
|
||||||
Use containerName, threadId, lastStepHash, and workflowName from your prompt.
|
|
||||||
All commands via: `docker exec <containerName> bash -c '...'`
|
|
||||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
|
||||||
|
|
||||||
Cancel:
|
|
||||||
1. Start a second thread: `uwf thread start <workflowName> -p 'E2E cancel test'`
|
|
||||||
2. Cancel it: `uwf thread cancel <secondThreadId>`
|
|
||||||
3. Verify it appears in completed list: `uwf thread list --status completed`
|
|
||||||
|
|
||||||
Fork:
|
|
||||||
4. Fork from the first thread's last step: `uwf step fork <lastStepHash>`
|
|
||||||
5. Verify fork creates a new thread with a different ID
|
|
||||||
|
|
||||||
Logs:
|
|
||||||
6. `uwf log list` — verify output (may be empty)
|
|
||||||
7. `uwf log show --thread <threadId>` — verify runs without error
|
|
||||||
|
|
||||||
Report results with summary.
|
|
||||||
output: "Report test results with summary. Set $status to pass or fail."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "pass" }
|
|
||||||
containerName: { type: string }
|
|
||||||
summary: { type: string }
|
|
||||||
required: [$status, containerName, summary]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fail" }
|
|
||||||
error: { type: string }
|
|
||||||
containerName: { type: string }
|
|
||||||
required: [$status, error, containerName]
|
|
||||||
|
|
||||||
cleanup:
|
|
||||||
description: "Remove Docker container"
|
|
||||||
goal: "You are an E2E test runner. Clean up the Docker container used for testing."
|
|
||||||
capabilities:
|
|
||||||
- docker
|
|
||||||
- shell
|
|
||||||
procedure: |
|
|
||||||
Remove the Docker container (containerName is in your prompt):
|
|
||||||
1. `docker rm -f <containerName>`
|
|
||||||
2. Verify the container is gone: `docker ps -a --filter name=<containerName> --format '{{.Names}}'` should return empty
|
|
||||||
|
|
||||||
Report cleanup result.
|
|
||||||
output: "Report cleanup result. Set $status to pass or fail."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "pass" }
|
|
||||||
summary: { type: string }
|
|
||||||
required: [$status, summary]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fail" }
|
|
||||||
error: { type: string }
|
|
||||||
required: [$status, error]
|
|
||||||
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
_: { role: "bootstrap", prompt: "Set up the Docker container and verify uwf is runnable." }
|
|
||||||
bootstrap:
|
|
||||||
pass: { role: "config-and-registry", prompt: "Container {{{containerName}}} is ready. Validate config and workflow registration." }
|
|
||||||
fail: { role: "$END", prompt: "Bootstrap failed: {{{error}}}. No container was created." }
|
|
||||||
config-and-registry:
|
|
||||||
pass: { role: "thread-ops", prompt: "Config and registry OK. Workflow '{{{workflowName}}}' registered. Container: {{{containerName}}}. Now test thread operations." }
|
|
||||||
fail: { role: "cleanup", prompt: "Config/registry failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
|
||||||
thread-ops:
|
|
||||||
pass: { role: "inspect", prompt: "Thread ops OK. threadId={{{threadId}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test inspect operations." }
|
|
||||||
fail: { role: "cleanup", prompt: "Thread ops failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
|
||||||
inspect:
|
|
||||||
pass: { role: "cancel-and-fork", prompt: "Inspect OK. threadId={{{threadId}}}, lastStepHash={{{lastStepHash}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test cancel, fork, and logs." }
|
|
||||||
fail: { role: "cleanup", prompt: "Inspect failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
|
||||||
cancel-and-fork:
|
|
||||||
pass: { role: "cleanup", prompt: "All tests passed! {{{summary}}}. Clean up container {{{containerName}}}." }
|
|
||||||
fail: { role: "cleanup", prompt: "Cancel/fork failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
|
||||||
cleanup:
|
|
||||||
pass: { role: "$END", prompt: "E2E walkthrough complete. {{{summary}}}" }
|
|
||||||
fail: { role: "$END", prompt: "Cleanup failed: {{{error}}}. Manual cleanup may be needed." }
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
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. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
|
||||||
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
|
|
||||||
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)
|
|
||||||
3. Set repoPath to the absolute path of the repository root
|
|
||||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "ready" }
|
|
||||||
plan: { type: string }
|
|
||||||
repoPath: { type: string }
|
|
||||||
required: [$status, plan, repoPath]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "insufficient_info" }
|
|
||||||
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: |
|
|
||||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
|
||||||
The repo path and other details are provided in your task prompt.
|
|
||||||
|
|
||||||
Before starting any work, set up an isolated worktree:
|
|
||||||
1. cd into the repo path provided in your task prompt
|
|
||||||
2. `git fetch origin` to get latest refs
|
|
||||||
3. First time (no existing branch):
|
|
||||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
|
||||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
|
||||||
4. If bounced back from reviewer or tester (branch already exists):
|
|
||||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
|
||||||
- `git fetch origin && git rebase origin/main`
|
|
||||||
5. ALL subsequent work must happen inside the worktree directory.
|
|
||||||
|
|
||||||
Then implement TDD:
|
|
||||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
|
||||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
|
||||||
8. Write tests first based on the spec
|
|
||||||
9. Implement the code to make tests pass
|
|
||||||
10. Ensure `bun run build` passes with no errors
|
|
||||||
11. Run `bun test` to verify all tests pass
|
|
||||||
|
|
||||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
|
||||||
or repeated attempts fail), set $status=failed with a reason.
|
|
||||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "done" }
|
|
||||||
branch: { type: string }
|
|
||||||
worktree: { type: string }
|
|
||||||
required: [$status, branch, worktree]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "failed" }
|
|
||||||
reason: { type: string }
|
|
||||||
required: [$status, reason]
|
|
||||||
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: |
|
|
||||||
The worktree path is provided in your task prompt. cd into it first.
|
|
||||||
|
|
||||||
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 project conventions if CLAUDE.md / .cursor/rules exist):
|
|
||||||
- Naming conventions, module boundaries, code style
|
|
||||||
- No `console.log` in production code
|
|
||||||
- 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. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "approved" }
|
|
||||||
branch: { type: string }
|
|
||||||
worktree: { type: string }
|
|
||||||
required: [$status, branch, worktree]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "rejected" }
|
|
||||||
comments: { type: string }
|
|
||||||
worktree: { type: string }
|
|
||||||
required: [$status, comments, worktree]
|
|
||||||
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: |
|
|
||||||
The worktree path is provided in your task prompt. cd into it first.
|
|
||||||
|
|
||||||
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 planner step in the thread history)
|
|
||||||
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. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "passed" }
|
|
||||||
branch: { type: string }
|
|
||||||
worktree: { type: string }
|
|
||||||
required: [$status, branch, worktree]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fix_code" }
|
|
||||||
report: { type: string }
|
|
||||||
required: [$status, report]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fix_spec" }
|
|
||||||
report: { type: string }
|
|
||||||
required: [$status, report]
|
|
||||||
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: |
|
|
||||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
|
||||||
cd into the worktree first.
|
|
||||||
|
|
||||||
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 --repo <owner/repo> --title "..." --description "..."`
|
|
||||||
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
|
||||||
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
|
||||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
|
||||||
5. After PR creation, clean up the worktree:
|
|
||||||
- cd to the repo root (parent of .worktrees)
|
|
||||||
- `git worktree remove <worktree-path>`
|
|
||||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "committed" }
|
|
||||||
prUrl: { type: string }
|
|
||||||
required: [$status, prUrl]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "hook_failed" }
|
|
||||||
error: { type: string }
|
|
||||||
required: [$status, error]
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
|
||||||
planner:
|
|
||||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
|
||||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
|
||||||
developer:
|
|
||||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
|
||||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
|
||||||
reviewer:
|
|
||||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
|
||||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
|
||||||
tester:
|
|
||||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
|
||||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
|
||||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
|
||||||
committer:
|
|
||||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
|
||||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
|
||||||
@@ -2,40 +2,45 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
This monorepo implements a stateless workflow engine driven by a single-step CLI (`uwf`). Workflows are **YAML definitions** stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
This monorepo implements a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier. Shared types live in `@uncaged/workflow-protocol`; bundle authors typically depend on `@uncaged/workflow-runtime`.
|
||||||
|
|
||||||
### Key Terms
|
### Key Terms
|
||||||
|
|
||||||
| Concept | What it is |
|
| Concept | What it is |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, status-based routing, and a directed graph. Stored as a CAS node, identified by its XXH64 hash. |
|
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
|
||||||
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
|
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||||
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
|
| **Thread** | A single execution of a workflow, identified by a ULID. State lives in CAS (linked nodes); active threads indexed in `threads.json`; completed rows in `history/*.jsonl`. Debug logs use `.info.jsonl`. |
|
||||||
| **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
|
||||||
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
|
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||||
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
|
|
||||||
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
|
|
||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
workflow/
|
workflow/
|
||||||
packages/
|
packages/
|
||||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
workflow-protocol/ # @uncaged/workflow-protocol — shared types + Result
|
||||||
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
workflow-runtime/ # @uncaged/workflow-runtime — createWorkflow, type re-exports
|
||||||
workflow-util-agent/ # @uncaged/workflow-util-agent — createAgent factory, context builder, extract pipeline
|
workflow-util/ # @uncaged/workflow-util — Base32, ULID, logger, storage paths, refs helpers
|
||||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
workflow-reactor/ # @uncaged/workflow-reactor — LLM fn + thread reactor (tool calls)
|
||||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary (includes status-based moderator in src/moderator/)
|
workflow-cas/ # @uncaged/workflow-cas — CAS store, hash, Merkle
|
||||||
legacy-packages/ # Archived packages (preserved for reference, not active)
|
workflow-register/ # @uncaged/workflow-register — bundle validation, registry YAML, model resolution
|
||||||
examples/ # Workflow YAML examples (solve-issue.yaml)
|
workflow-execute/ # @uncaged/workflow-execute — engine, extract, fork, GC, workflowAsAgent
|
||||||
docs/ # Architecture docs
|
cli-workflow/ # @uncaged/cli-workflow — uncaged-workflow CLI
|
||||||
biome.json # root Biome config
|
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||||
tsconfig.json # root TypeScript config
|
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||||
|
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||||
|
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||||
|
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||||
|
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||||
|
workflow-dashboard/ # @uncaged/workflow-dashboard — React dashboard (private app)
|
||||||
|
docs/ # RFCs, conventions
|
||||||
|
biome.json # root Biome config
|
||||||
|
tsconfig.json # root TypeScript config
|
||||||
```
|
```
|
||||||
|
|
||||||
- Dependency layers: `workflow-protocol` → `workflow-util` → `workflow-util-agent` → `workflow-agent-hermes` / `cli-workflow`
|
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
- Packages use `workspace:*` protocol
|
||||||
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
|
|
||||||
|
|
||||||
## Language & Paradigm
|
## Language & Paradigm
|
||||||
|
|
||||||
@@ -103,6 +108,8 @@ type WorkflowEntry = {
|
|||||||
- Always named exports, never default exports
|
- Always named exports, never default exports
|
||||||
- One module = one responsibility, filename = purpose
|
- One module = one responsibility, filename = purpose
|
||||||
|
|
||||||
|
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
|
||||||
|
|
||||||
### Folder Module Discipline
|
### Folder Module Discipline
|
||||||
|
|
||||||
Every folder under `src/` is a **module boundary**. Four rules:
|
Every folder under `src/` is a **module boundary**. Four rules:
|
||||||
@@ -128,10 +135,10 @@ export { createCasStore } from "../cas/cas.js";
|
|||||||
|
|
||||||
// ❌ Bad — types defined in index.ts
|
// ❌ Bad — types defined in index.ts
|
||||||
// in cas/index.ts:
|
// in cas/index.ts:
|
||||||
export type CasStore = { ... }; // should be in cas/types.ts
|
export type CasStore = { ... }; // should be in cas/types.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`) are not inside a folder module and follow normal rules.
|
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
@@ -152,7 +159,7 @@ Workflow names use **verb-first** kebab-case:
|
|||||||
### ID Encoding
|
### ID Encoding
|
||||||
|
|
||||||
All IDs use **Crockford Base32**:
|
All IDs use **Crockford Base32**:
|
||||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
- Bundle hash: XXH64 → 13-char Crockford Base32
|
||||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
@@ -181,7 +188,7 @@ import { createLogger } from "@uncaged/workflow-util";
|
|||||||
const log = createLogger();
|
const log = createLogger();
|
||||||
|
|
||||||
// Each call site has a fixed 8-char Crockford Base32 tag
|
// Each call site has a fixed 8-char Crockford Base32 tag
|
||||||
log("4KNMR2PX", "Loading workflow...");
|
log("4KNMR2PX", "Loading workflow bundle...");
|
||||||
log("7BQST3VW", `Role ${role} started`);
|
log("7BQST3VW", `Role ${role} started`);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -196,7 +203,7 @@ log("7BQST3VW", `Role ${role} started`);
|
|||||||
|
|
||||||
### Why fixed tags?
|
### Why fixed tags?
|
||||||
|
|
||||||
- `grep "4KNMR2PX"` in logs → instant code location
|
- `grep "4KNMR2PX"` in `.info.jsonl` → instant code location
|
||||||
- No need for file/line info in the log — tag is the locator
|
- No need for file/line info in the log — tag is the locator
|
||||||
- Survives refactoring (tag stays the same when code moves)
|
- Survives refactoring (tag stays the same when code moves)
|
||||||
|
|
||||||
@@ -213,81 +220,119 @@ console.log(result);
|
|||||||
|
|
||||||
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
||||||
|
|
||||||
|
**Exception**: The bundle loader and `extractBundleExports` dynamically import user workflow files at runtime.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Dynamic import required: user bundle path resolved at runtime
|
||||||
|
const mod = await import(bundlePath);
|
||||||
|
```
|
||||||
|
|
||||||
Test files (`__tests__/**`) are exempt.
|
Test files (`__tests__/**`) are exempt.
|
||||||
|
|
||||||
|
## Package Build & Distribution
|
||||||
|
|
||||||
|
每个包用 `tsc` 编译出 `.js` + `.d.ts` 到 `dist/`,**发布 `dist`,不发 `src`,不 bundle**。
|
||||||
|
|
||||||
|
### 规则
|
||||||
|
|
||||||
|
- ✅ `tsc --build` 编译,保留目录结构(tree-shaking 友好)
|
||||||
|
- ✅ `package.json` 的 `exports` 指向 `dist/`(编译产物),不指向 `src/`
|
||||||
|
- ✅ 消费端不受 tsconfig 差异影响
|
||||||
|
- ❌ 不要用 rollup/esbuild 打成单文件
|
||||||
|
- ❌ `exports` 中不要出现 `./src/`
|
||||||
|
|
||||||
|
### package.json exports 格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
多入口同理,所有路径指向 `dist/`。
|
||||||
|
|
||||||
|
### 例外
|
||||||
|
|
||||||
|
- `workflow-gateway`(Cloudflare Workers)和 `workflow-dashboard`(私有 app)不发 npm,exports 可指向 `src/`。
|
||||||
|
|
||||||
## Toolchain
|
## Toolchain
|
||||||
|
|
||||||
| Tool | Purpose |
|
| Tool | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| **bun** | Package manager + runtime |
|
| **bun** | Package manager + runtime + test runner |
|
||||||
| **TypeScript** | Type checking (strict mode) |
|
| **TypeScript** | Type checking (strict mode) |
|
||||||
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
||||||
| **vitest** | Test runner (`cli-workflow` uses vitest; other packages use `bun test`) |
|
|
||||||
|
|
||||||
### Development Workflow
|
### Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ── Setup ──
|
bun run check # tsc --build + biome check
|
||||||
bun install # install all workspace dependencies
|
bun run format # biome format --write
|
||||||
|
bun test # run tests
|
||||||
# ── Daily development ──
|
|
||||||
bun run build # tsc --build (all packages, dependency order)
|
|
||||||
bun run check # tsc --build + biome check + lint-log-tags
|
|
||||||
bun run format # biome format --write
|
|
||||||
bun test # run tests across all packages
|
|
||||||
|
|
||||||
# ── Before committing ──
|
|
||||||
bun run check # must pass — typecheck + lint + log tag validation
|
|
||||||
bun test # must pass — all package tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Publishing
|
### Publishing to Gitea npm Registry
|
||||||
|
|
||||||
All public `@uncaged/*` packages are published to **npmjs.org** with **fixed mode** (all packages share the same version number).
|
All public `@uncaged/*` packages are published to the Gitea npm registry at `git.shazhou.work`. Workflow workspaces consume packages from this registry via `bunfig.toml`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Add a changeset describing the change
|
# Publish all packages (bun pm pack resolves workspace:* → actual versions)
|
||||||
bun changeset
|
bun run publish:gitea
|
||||||
|
|
||||||
# 2. Bump all package versions + generate CHANGELOGs
|
# Dry run — see what would be published
|
||||||
bun version
|
bun run publish:gitea:dry
|
||||||
|
|
||||||
# 3. Build, test, and publish (runs scripts/publish-all.mjs)
|
|
||||||
bun release
|
|
||||||
|
|
||||||
# Or publish manually with a tag:
|
|
||||||
node scripts/publish-all.mjs --tag alpha
|
|
||||||
node scripts/publish-all.mjs --dry-run # preview without publishing
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
Prerequisites: `.npmrc` in monorepo root with Gitea auth token (`//git.shazhou.work/api/packages/shazhou/npm/:_authToken=<token>`).
|
||||||
- Publish order defined in `scripts/publish-all.mjs` (dependency order)
|
|
||||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
|
||||||
|
|
||||||
### End-to-end: Author → Register → Run
|
### Workflow Workspace Setup
|
||||||
|
|
||||||
```
|
External workflow repos (e.g. `xingyue-workflows`) use the Gitea registry for `@uncaged/*` packages. Add a `bunfig.toml`:
|
||||||
examples/solve-issue.yaml — write a workflow YAML definition
|
|
||||||
│ uwf workflow put
|
```toml
|
||||||
▼
|
[install.scopes]
|
||||||
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
|
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||||
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
|
|
||||||
│ uwf thread start <name> -p "..."
|
|
||||||
▼
|
|
||||||
~/.uncaged/workflow/threads.yaml — new thread head pointer
|
|
||||||
│ uwf thread step <thread-id>
|
|
||||||
▼
|
|
||||||
moderator → agent → extract — one step per invocation, repeat until $END
|
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Author** — write a workflow YAML file with roles, conditions, and graph
|
Then `bun install` resolves `@uncaged/*` from Gitea, all other packages from npmjs.
|
||||||
2. **Register** — `uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
|
|
||||||
3. **Run** — `uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
|
|
||||||
|
|
||||||
## Project Rules
|
### Cross-repo Development (bun link)
|
||||||
|
|
||||||
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
|
Alternative for development against un-published local changes:
|
||||||
- [docs/no-dynamic-import.md](docs/no-dynamic-import.md) — no dynamic import in production code
|
|
||||||
|
```bash
|
||||||
|
bun run link # Register all packages (from monorepo root)
|
||||||
|
bun run link:consume # Link into CWD's project (⚠️ don't bun install after)
|
||||||
|
bun run link:unlink # Restore original deps
|
||||||
|
```
|
||||||
|
|
||||||
|
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||||
|
|
||||||
|
The recommended development flow for building workflows:
|
||||||
|
|
||||||
|
```
|
||||||
|
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||||
|
│ bun run publish:gitea — auto topo-sort, bun pm pack → npm publish
|
||||||
|
▼
|
||||||
|
git.shazhou.work npm registry — @uncaged/* scoped packages
|
||||||
|
│ bun install — via bunfig.toml scoped registry
|
||||||
|
▼
|
||||||
|
my-workflows/ (workspace) — bunfig.toml + normal package.json
|
||||||
|
│ bun run build:develop — bun build → single .esm.js
|
||||||
|
▼
|
||||||
|
uncaged-workflow workflow add — register bundle locally
|
||||||
|
uncaged-workflow run — execute workflow
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Monorepo changes** → `bun run publish:gitea` (packages auto-discovered from `packages/*/`, topologically sorted, `workspace:*` resolved to real versions)
|
||||||
|
2. **Workspace** → `bun install` fetches latest from Gitea, `bun install` is safe to run anytime
|
||||||
|
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||||
|
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||||
|
|
||||||
## Commit Convention
|
## Commit Convention
|
||||||
|
|
||||||
@@ -295,5 +340,5 @@ moderator → agent → extract — one step per invocation, repeat until $
|
|||||||
<type>(<scope>): <description>
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
type: feat | fix | refactor | docs | chore | test
|
type: feat | fix | refactor | docs | chore | test
|
||||||
scope: workflow | cli | moderator | agent-kit | hermes | util | protocol | ...
|
scope: workflow | cli | rfc-001 | ...
|
||||||
```
|
```
|
||||||
|
|||||||
-109
@@ -1,109 +0,0 @@
|
|||||||
# Contributing to @uncaged/workflow
|
|
||||||
|
|
||||||
Thank you for your interest in contributing! This guide covers setup, conventions, and the PR workflow.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- [Bun](https://bun.sh/) (latest)
|
|
||||||
- [Node.js](https://nodejs.org/) 20+
|
|
||||||
- Git
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/shazhou-ww/uncaged-workflow.git
|
|
||||||
cd uncaged-workflow
|
|
||||||
bun install
|
|
||||||
bun run build
|
|
||||||
bun test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run build # TypeScript compilation (all packages)
|
|
||||||
bun run check # tsc + biome lint + log tag validation
|
|
||||||
bun run format # Auto-format with Biome
|
|
||||||
bun test # Run all tests
|
|
||||||
```
|
|
||||||
|
|
||||||
All three (`build`, `check`, `test`) must pass before submitting a PR. A pre-push hook runs `check` + `test` automatically.
|
|
||||||
|
|
||||||
## Coding Conventions
|
|
||||||
|
|
||||||
See [CLAUDE.md](CLAUDE.md) for the full coding standard. Key points:
|
|
||||||
|
|
||||||
- **Functional-first** — `function` + `type`, not `class` + `interface`
|
|
||||||
- **No optional properties** — use `T | null` instead of `?:`
|
|
||||||
- **Named exports only** — no default exports
|
|
||||||
- **No `console.log`** — use the structured logger from `@uncaged/workflow-util`
|
|
||||||
- **Static imports only** — no `await import()` in production code
|
|
||||||
- **Biome** for lint + format — run `bun run check` before committing
|
|
||||||
|
|
||||||
## Commit Messages
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>(<scope>): <description>
|
|
||||||
|
|
||||||
type: feat | fix | refactor | docs | chore | test
|
|
||||||
scope: cli | moderator | agent-kit | hermes | builtin | claude-code | util | protocol | dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- `feat(moderator): add cycle detection to graph evaluator`
|
|
||||||
- `fix(cli): handle missing config file gracefully`
|
|
||||||
- `docs(protocol): update StepNode field descriptions`
|
|
||||||
|
|
||||||
## Pull Request Process
|
|
||||||
|
|
||||||
1. **Branch** from `main`: `git checkout -b feat/123-short-description`
|
|
||||||
2. **Implement** your change with tests
|
|
||||||
3. **Run checks**: `bun run check && bun test`
|
|
||||||
4. **Commit** with a descriptive message referencing the issue: `Fixes #123`
|
|
||||||
5. **Push** and open a PR
|
|
||||||
|
|
||||||
### PR Description Template
|
|
||||||
|
|
||||||
```
|
|
||||||
## What
|
|
||||||
What this PR does.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
Why the change is needed.
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
- `path/to/file.ts` — what changed and why
|
|
||||||
|
|
||||||
## Ref
|
|
||||||
Fixes #N
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a Changeset
|
|
||||||
|
|
||||||
For any user-facing change (feat, fix, breaking change), add a changeset:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun changeset
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a markdown file in `.changeset/` describing the change. It will be consumed on the next release to bump versions and generate CHANGELOG entries.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/
|
|
||||||
workflow-protocol/ # Shared types and JSON Schema
|
|
||||||
workflow-util/ # Encoding, IDs, logging, frontmatter
|
|
||||||
workflow-util-agent/ # createAgent factory, extract pipeline
|
|
||||||
workflow-agent-hermes/ # Hermes ACP agent
|
|
||||||
workflow-agent-builtin/ # Built-in LLM agent
|
|
||||||
workflow-agent-claude-code/ # Claude Code agent
|
|
||||||
cli-workflow/ # uwf CLI binary
|
|
||||||
workflow-dashboard/ # Web UI (private, alpha)
|
|
||||||
```
|
|
||||||
|
|
||||||
Dependency flows downward — lower layers have no dependency on higher layers. See [CLAUDE.md](CLAUDE.md) for the full architecture.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Uncaged
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,115 +1,71 @@
|
|||||||
# @uncaged/workflow
|
# @uncaged/workflow
|
||||||
|
|
||||||
[](https://github.com/shazhou-ww/uncaged-workflow/actions/workflows/ci.yml)
|
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
|
||||||
[](https://www.npmjs.com/package/@uncaged/cli-workflow)
|
|
||||||
[](https://www.npmjs.com/package/@uncaged/workflow-protocol)
|
|
||||||
[](https://www.npmjs.com/package/@uncaged/workflow-util-agent)
|
|
||||||
|
|
||||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, status-based routing, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
## Core Concepts
|
||||||
|
|
||||||
## Overview
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||||
|
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||||
|
| **Thread** | A single execution of a workflow, identified by a ULID. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
|
||||||
|
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||||
|
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||||
|
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||||
|
|
||||||
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates status-based routing to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
|
## Monorepo Packages
|
||||||
|
|
||||||
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
|
```
|
||||||
|
packages/
|
||||||
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
|
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
|
||||||
|
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
|
||||||
## Install
|
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
|
||||||
|
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
|
||||||
```bash
|
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
|
||||||
npm install -g @uncaged/cli-workflow
|
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
|
||||||
|
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
|
||||||
|
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires [Bun](https://bun.sh/) runtime (used internally for TypeScript execution).
|
Managed with **bun workspace** using the `workspace:*` protocol.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Configure provider, model, and default agent
|
# Install dependencies
|
||||||
uwf setup
|
bun install
|
||||||
|
|
||||||
# 2. Register a workflow from YAML
|
# Build all packages
|
||||||
uwf workflow add examples/solve-issue.yaml
|
bun run build
|
||||||
|
|
||||||
# 3. Start a thread (creates head pointer; does not execute)
|
# Register a workflow bundle
|
||||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||||
|
|
||||||
# 4. Execute steps (one at a time, until done)
|
# Run a workflow
|
||||||
uwf thread exec <thread-id>
|
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
## CLI Usage
|
||||||
|
|
||||||
## Architecture
|
```bash
|
||||||
|
uncaged-workflow # Print full command usage (exits with status 1)
|
||||||
Dependency layers (lower layers have no dependency on higher layers):
|
uncaged-workflow workflow list # List registered workflows
|
||||||
|
uncaged-workflow run <name> # Start a workflow thread
|
||||||
```
|
uncaged-workflow thread list # List all threads
|
||||||
Layer 0 — Contract
|
uncaged-workflow thread show <id> # Inspect a thread
|
||||||
workflow-protocol Shared types and JSON Schema definitions
|
uncaged-workflow skill # Agent-consumable reference docs
|
||||||
|
|
||||||
Layer 1 — Shared infra
|
|
||||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
|
||||||
|
|
||||||
Layer 2 — Agent framework
|
|
||||||
workflow-util-agent createAgent factory, context builder, extract pipeline
|
|
||||||
|
|
||||||
Layer 3 — Agent implementations
|
|
||||||
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
|
|
||||||
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
|
|
||||||
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
|
|
||||||
|
|
||||||
Layer 4 — CLI
|
|
||||||
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup (includes status-based moderator)
|
|
||||||
|
|
||||||
App (uses protocol; not in the runtime engine stack)
|
|
||||||
workflow-dashboard Web UI for visual workflow editing
|
|
||||||
```
|
```
|
||||||
|
|
||||||
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
|
||||||
|
|
||||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
| Package | npm | Description | Type | README |
|
|
||||||
|---------|-----|-------------|------|--------|
|
|
||||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
|
|
||||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
|
|
||||||
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-util-agent/README.md) |
|
|
||||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
|
|
||||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
|
||||||
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
|
|
||||||
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
|
|
||||||
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
|
|
||||||
|
|
||||||
## CLI Reference
|
|
||||||
|
|
||||||
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
|
||||||
|
|
||||||
| Group | Commands |
|
|
||||||
|-------|----------|
|
|
||||||
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
|
|
||||||
| **step** | `list`, `show`, `read`, `fork` |
|
|
||||||
| **workflow** | `add`, `show`, `list` |
|
|
||||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
|
||||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
|
||||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
|
||||||
| **log** | `list`, `show`, `clean` — process-level debug logs |
|
|
||||||
|
|
||||||
Config is stored in `~/.uncaged/workflow/config.yaml`. API keys go in `~/.uncaged/workflow/.env`.
|
|
||||||
|
|
||||||
Detailed command usage, options, and examples: [packages/cli-workflow/README.md](packages/cli-workflow/README.md).
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install --no-cache # Install dependencies
|
bun run check # Biome lint + format check
|
||||||
bun run build # tsc --build (all packages)
|
bun run format # Auto-format with Biome
|
||||||
bun run check # tsc + biome + lint-log-tags
|
bun test # Run tests
|
||||||
bun run format # Auto-format with Biome
|
|
||||||
bun test # Run all tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
|
## Architecture
|
||||||
|
|
||||||
|
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
|
||||||
|
|||||||
+3
-31
@@ -1,16 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||||
"**",
|
|
||||||
"!**/dist",
|
|
||||||
"!.worktrees",
|
|
||||||
"!**/node_modules",
|
|
||||||
"!**/legacy-packages",
|
|
||||||
"!scripts",
|
|
||||||
"!packages/workflow/workflow",
|
|
||||||
"!xiaoju/scripts/bundle.ts"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
@@ -18,15 +9,6 @@
|
|||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"lineWidth": 100
|
"lineWidth": 100
|
||||||
},
|
},
|
||||||
"css": {
|
|
||||||
"parser": {
|
|
||||||
"cssModules": true,
|
|
||||||
"tailwindDirectives": true
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"quoteStyle": "double",
|
"quoteStyle": "double",
|
||||||
@@ -48,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"includes": ["**/*.d.ts", "**/vitest.config.*"],
|
"includes": ["**/*.d.ts"],
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"style": {
|
"style": {
|
||||||
@@ -56,16 +38,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"includes": ["**/cli.ts", "**/setup.ts"],
|
|
||||||
"linter": {
|
|
||||||
"rules": {
|
|
||||||
"suspicious": {
|
|
||||||
"noConsole": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
+179
-401
@@ -1,490 +1,268 @@
|
|||||||
# Workflow Engine — Architecture
|
# Uncaged workflow — Architecture
|
||||||
|
|
||||||
**Last updated:** 2026-05-19
|
**Last updated:** 2026-05-09
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
||||||
|
|
||||||
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
|
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||||
|
|
||||||
## Package map
|
## Package map
|
||||||
|
|
||||||
|
Grouped by responsibility (npm name → folder).
|
||||||
|
|
||||||
| Layer | Package | One-line role |
|
| Layer | Package | One-line role |
|
||||||
|-------|---------|---------------|
|
|-------|---------|----------------|
|
||||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
|
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types and `Result` helpers; peer `zod` only — no other workspace deps. |
|
||||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
|
| Author API | `@uncaged/workflow-runtime` → `workflow-runtime` | `createWorkflow` and re-exports of protocol workflow types for bundle authors. |
|
||||||
| Agent framework | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Base32/ULID, logger, storage root paths, global CAS dir, ref-field helpers. |
|
||||||
| Agent: Hermes | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
|
| LLM plumbing | `@uncaged/workflow-reactor` → `workflow-reactor` | `createLlmFn`, `createThreadReactor`, and related tool-call types for threaded LLM invocation. |
|
||||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. Includes status-based graph evaluator in `src/moderator/` (next role or `$END`). |
|
| CAS | `@uncaged/workflow-cas` → `workflow-cas` | `CasStore` implementation, XXH64 hashing, Merkle helpers over CAS payloads. |
|
||||||
|
| Registry / bundles | `@uncaged/workflow-register` → `workflow-register` | Bundle validation & dynamic export extraction, `workflow.yaml` registry I/O, provider/model resolution. |
|
||||||
|
| Engine | `@uncaged/workflow-execute` → `workflow-execute` | Thread execution, worker entry path, fork/GC, extract pipeline, `workflowAsAgent`. |
|
||||||
|
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
|
||||||
|
| Agent adapters | `@uncaged/workflow-agent-cursor` → `workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
|
||||||
|
| | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
|
||||||
|
| | `@uncaged/workflow-agent-llm` → `workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
|
||||||
|
| Agent shared | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
|
||||||
|
| Templates | `@uncaged/workflow-template-develop` → `workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
|
||||||
|
| | `@uncaged/workflow-template-solve-issue` → `workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
|
||||||
|
| Dashboard | `@uncaged/workflow-dashboard` → `workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
|
||||||
|
|
||||||
### External dependencies
|
## Dependency graph (workspace packages)
|
||||||
|
|
||||||
| Package | Role |
|
Bottom-up layering for the execution stack:
|
||||||
|---------|------|
|
|
||||||
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
|
||||||
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
|
|
||||||
| `mustache` | Template renderer for edge prompts (used by `cli-workflow` moderator). |
|
|
||||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
|
||||||
| `dotenv` | Loads `.env` files for API keys. |
|
|
||||||
| `yaml` | YAML parse/stringify. |
|
|
||||||
|
|
||||||
## Dependency graph
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart BT
|
flowchart BT
|
||||||
subgraph External
|
|
||||||
jcas["@uncaged/json-cas"]
|
|
||||||
jcasfs["@uncaged/json-cas-fs"]
|
|
||||||
end
|
|
||||||
subgraph L0["Layer 0 — contract"]
|
subgraph L0["Layer 0 — contract"]
|
||||||
protocol["@uncaged/workflow-protocol"]
|
protocol["@uncaged/workflow-protocol"]
|
||||||
end
|
end
|
||||||
subgraph L1["Layer 1 — shared"]
|
subgraph L1["Layer 1 — on protocol"]
|
||||||
|
runtime["@uncaged/workflow-runtime"]
|
||||||
util["@uncaged/workflow-util"]
|
util["@uncaged/workflow-util"]
|
||||||
|
reactor["@uncaged/workflow-reactor"]
|
||||||
end
|
end
|
||||||
subgraph L2["Layer 2 — agent framework"]
|
subgraph L2["Layer 2 — protocol + util"]
|
||||||
kit["@uncaged/workflow-util-agent"]
|
cas["@uncaged/workflow-cas"]
|
||||||
|
register["@uncaged/workflow-register"]
|
||||||
end
|
end
|
||||||
subgraph L3["Layer 3 — agent implementations"]
|
subgraph L3["Layer 3 — engine"]
|
||||||
hermes["@uncaged/workflow-agent-hermes"]
|
execute["@uncaged/workflow-execute"]
|
||||||
end
|
end
|
||||||
subgraph L4["Layer 4 — CLI"]
|
subgraph L4["Layer 4 — CLI"]
|
||||||
cli["@uncaged/cli-workflow"]
|
cli["@uncaged/cli-workflow"]
|
||||||
end
|
end
|
||||||
protocol --> jcasfs
|
runtime --> protocol
|
||||||
util --> protocol
|
util --> protocol
|
||||||
kit --> protocol
|
reactor --> protocol
|
||||||
kit --> util
|
cas --> protocol
|
||||||
kit --> jcas
|
cas --> util
|
||||||
kit --> jcasfs
|
register --> protocol
|
||||||
hermes --> kit
|
register --> util
|
||||||
hermes --> jcas
|
execute --> protocol
|
||||||
|
execute --> runtime
|
||||||
|
execute --> util
|
||||||
|
execute --> cas
|
||||||
|
execute --> reactor
|
||||||
|
execute --> register
|
||||||
cli --> protocol
|
cli --> protocol
|
||||||
cli --> util
|
cli --> util
|
||||||
cli --> kit
|
cli --> cas
|
||||||
cli --> jcas
|
cli --> execute
|
||||||
cli --> jcasfs
|
cli --> register
|
||||||
|
cli --> runtime
|
||||||
```
|
```
|
||||||
|
|
||||||
## Workflow definition
|
**Adjacent consumers** (not in the main CLI stack):
|
||||||
|
|
||||||
Workflows are **YAML files** (not ESM bundles). `uwf workflow put <file.yaml>` parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the `WorkflowPayload` as a CAS node.
|
- `@uncaged/workflow-util-agent` → `@uncaged/workflow-runtime`
|
||||||
|
- `@uncaged/workflow-agent-llm` → `@uncaged/workflow-runtime`
|
||||||
|
- `@uncaged/workflow-agent-cursor` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`, `zod`
|
||||||
|
- `@uncaged/workflow-agent-hermes` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`
|
||||||
|
- `@uncaged/workflow-template-develop` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod`
|
||||||
|
- `@uncaged/workflow-template-solve-issue` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod` (dev-only workspace deps: `@uncaged/workflow-cas`, `@uncaged/workflow-execute` for tests/tooling per `package.json`)
|
||||||
|
|
||||||
Example (`examples/solve-issue.yaml`):
|
## Package roles (detail)
|
||||||
|
|
||||||
```yaml
|
- **`workflow-protocol`** — Pure types (`WorkflowFn`, contexts, `CasStore` interface, descriptor shapes), `START` / `END`, `ok` / `err`. Depends only on peer `zod` for schema-related types in signatures.
|
||||||
name: "solve-issue"
|
- **`workflow-runtime`** — Workflow author surface: `createWorkflow` from `src/create-workflow.js`, re-exports protocol types/constants used when authoring bundles.
|
||||||
description: "End-to-end issue resolution"
|
- **`workflow-util`** — Cross-cutting utilities: Crockford Base32, ULID, `createLogger`, `getDefaultWorkflowStorageRoot`, `getGlobalCasDir`, ref normalization; re-exports `ok`/`err` from protocol.
|
||||||
roles:
|
- **`workflow-cas`** — Filesystem CAS (`createCasStore`), `hashString` / `hashWorkflowBundleBytes`, Merkle node serialization and helpers (`merkle.js`).
|
||||||
planner:
|
- **`workflow-register`** — Bundle pipeline (`validateWorkflowBundle`, `extractBundleExports`, descriptor builders), registry YAML read/write, `resolveModel` / `splitProviderModelRef`.
|
||||||
description: "Creates implementation plan"
|
- **`workflow-execute`** — `executeThread`, supervisor/worker wiring (`engine/`), fork/GC/pause gate, `createExtract` + LLM extract helpers (`extract/`), `workflowAsAgent`. Imports `@uncaged/workflow-reactor` for LLM-backed extract/supervisor paths (`extract-fn.ts`, `supervisor.ts`).
|
||||||
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
- **`workflow-reactor`** — `createLlmFn`, `createThreadReactor`, and thread tool-invocation types — consumed by `workflow-execute`.
|
||||||
capabilities:
|
- **`cli-workflow`** — CLI commands and HTTP/dashboard-related wiring (`hono`, `yaml`); composes register + execute + CAS + util.
|
||||||
- issue-analysis
|
- **`workflow-agent-*`** — Replaceable `AgentFn` implementations (Cursor / Hermes CLIs, or HTTP LLM).
|
||||||
- planning
|
- **`workflow-util-agent`** — Shared prompt assembly and subprocess spawning for CLI agents.
|
||||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
- **`workflow-template-*`** — Concrete `WorkflowDefinition` graphs + Zod role schemas + descriptor builders for publishing bundles.
|
||||||
output: "Output the plan summary and list of concrete steps."
|
- **`workflow-dashboard`** — Standalone React UI; no published library entry matching `src/index.ts`.
|
||||||
meta:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
plan: { type: string }
|
|
||||||
steps: { type: array, items: { type: string } }
|
|
||||||
required: [plan, steps]
|
|
||||||
developer:
|
|
||||||
description: "Implements code changes"
|
|
||||||
goal: "You are a developer agent. Implement the plan."
|
|
||||||
capabilities:
|
|
||||||
- file-edit
|
|
||||||
- shell
|
|
||||||
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
|
||||||
output: "List all files changed and provide a summary of the implementation."
|
|
||||||
meta:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
filesChanged: { type: array, items: { type: string } }
|
|
||||||
summary: { type: string }
|
|
||||||
required: [filesChanged, summary]
|
|
||||||
reviewer:
|
|
||||||
description: "Reviews code changes"
|
|
||||||
goal: "You are a code reviewer. Review the implementation."
|
|
||||||
capabilities:
|
|
||||||
- code-review
|
|
||||||
procedure: "Review the implementation against the plan."
|
|
||||||
output: "Approve or reject with detailed comments."
|
|
||||||
meta:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
approved: { type: boolean }
|
|
||||||
comments: { type: string }
|
|
||||||
required: [approved, comments]
|
|
||||||
conditions:
|
|
||||||
notApproved:
|
|
||||||
description: "Reviewer rejected the implementation"
|
|
||||||
expression: "steps[-1].output.approved = false"
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
- role: "planner"
|
|
||||||
condition: null
|
|
||||||
planner:
|
|
||||||
- role: "developer"
|
|
||||||
condition: null
|
|
||||||
developer:
|
|
||||||
- role: "reviewer"
|
|
||||||
condition: null
|
|
||||||
reviewer:
|
|
||||||
- role: "developer"
|
|
||||||
condition: "notApproved"
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
```
|
|
||||||
|
|
||||||
Key properties:
|
|
||||||
|
|
||||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
|
||||||
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
|
|
||||||
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
|
||||||
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
|
|
||||||
|
|
||||||
## Three-phase engine loop
|
## Three-phase engine loop
|
||||||
|
|
||||||
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli-workflow/src/commands/thread.ts` (`cmdThreadStep`).
|
Each role round is implemented in `packages/workflow-runtime/src/create-workflow.ts` (`advanceOneRound`): moderator → agent → extractor, with progressive context types from `@uncaged/workflow-protocol`.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─→ Phase 1: MODERATOR
|
┌─→ Phase 1: MODERATOR
|
||||||
│ Input: graph + lastRole + lastOutput
|
│ Context: ModeratorContext { threadId, depth, start, steps }
|
||||||
│ Engine: Status-based map lookup against lastOutput.status
|
│ Action: moderator(ctx) → role name | END
|
||||||
│ Output: next role name | $END
|
|
||||||
│
|
│
|
||||||
│ Phase 2: AGENT
|
│ Phase 2: AGENT
|
||||||
│ Input: thread-id + role (via argv)
|
│ Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
|
||||||
│ Engine: agent-kit builds context from CAS chain, prepends
|
│ Action: agent(ctx) → raw string
|
||||||
│ output format instruction to system prompt, spawns agent
|
|
||||||
│ Output: raw string (frontmatter markdown)
|
|
||||||
│
|
│
|
||||||
│ Phase 3: EXTRACT
|
│ Phase 3: EXTRACTOR
|
||||||
│ Input: raw agent output + role's meta schema
|
│ Context: ExtractContext = AgentCtx + { agentContent }
|
||||||
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
|
│ Action: runtime.extract(schema, extractPrompt, ctx) → typed meta
|
||||||
│ Output: CasRef to structured output node
|
|
||||||
│
|
│
|
||||||
│ Persist: StepNode { start, prev, role, output, detail, agent }
|
│ Merge: RoleStep { role, contentHash, meta, refs, timestamp }
|
||||||
│ Update: threads.yaml head pointer
|
│ Append to steps
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Context types
|
### Context types (progressive)
|
||||||
|
|
||||||
Defined in `packages/workflow-protocol/src/types.ts`:
|
Defined in `packages/workflow-protocol/src/types.ts`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type StepContext = {
|
type ModeratorContext<M> = ThreadContext<M>;
|
||||||
role: string;
|
type AgentContext<M> = ModeratorContext<M> & {
|
||||||
output: unknown; // CAS node payload, expanded (not hash)
|
currentRole: { name: string; systemPrompt: string };
|
||||||
detail: CasRef;
|
|
||||||
agent: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ModeratorContext = {
|
|
||||||
start: StartNodePayload; // { workflow: CasRef, prompt: string }
|
|
||||||
steps: StepContext[]; // chronological, oldest first
|
|
||||||
};
|
|
||||||
|
|
||||||
type AgentContext = ModeratorContext & {
|
|
||||||
threadId: ThreadId;
|
|
||||||
role: string;
|
|
||||||
store: Store;
|
|
||||||
workflow: WorkflowPayload;
|
|
||||||
outputFormatInstruction: string;
|
|
||||||
};
|
};
|
||||||
|
type ExtractContext<M> = AgentContext<M> & { agentContent: string };
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key properties
|
### Key properties
|
||||||
|
|
||||||
- **Moderator** — pure status-based map lookup; no LLM call, no I/O beyond CAS reads. Looks up `graph[lastRole][lastOutput.status]` to get the next target.
|
- **Moderator is synchronous and pure** — no I/O, no state mutation inside `createWorkflow`’s moderator call path.
|
||||||
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
|
- **Agent receives `AgentContext`** — reads `ctx.currentRole.systemPrompt`; raw output becomes `agentContent` for extract.
|
||||||
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
|
- **Extractor is `WorkflowRuntime.extract`** — supplied by the engine from registry-resolved LLM config (`workflow-execute`); stores agent body in CAS and yields `contentHash` + `refs` on each step (`create-workflow.ts`).
|
||||||
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
|
- **`extractPrompt` is a call parameter** on `RoleDefinition`, not implicit context state.
|
||||||
|
|
||||||
## Agent CLI protocol
|
## Agent information sources
|
||||||
|
|
||||||
Each agent is an external command invoked by `uwf thread step`:
|
An agent has exactly three information sources:
|
||||||
|
|
||||||
```bash
|
1. **Prior knowledge** — LLM training, agent memory, agent skills
|
||||||
<agent-cmd> <thread-id> <role>
|
2. **Thread context** — `AgentContext` (`start`, `steps`, `currentRole`)
|
||||||
|
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
|
||||||
|
|
||||||
|
No hidden environment parameters. If an agent needs something (like a workspace path), it obtains it via `ExtractFn` (e.g. Cursor agent).
|
||||||
|
|
||||||
|
## Bundle contract
|
||||||
|
|
||||||
|
A workflow bundle is a single `.esm.js` file with two named exports (see `WorkflowFn` / `WorkflowDescriptor` in `packages/workflow-protocol/src/types.ts`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const descriptor: WorkflowDescriptor;
|
||||||
|
export const run: WorkflowFn;
|
||||||
|
|
||||||
|
type WorkflowFn = (
|
||||||
|
thread: ThreadContext,
|
||||||
|
runtime: WorkflowRuntime,
|
||||||
|
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||||
```
|
```
|
||||||
|
|
||||||
Contract:
|
`RoleOutput` carries `contentHash`, `meta`, and `refs` (agent text lives in CAS, addressed by hash).
|
||||||
1. `uwf thread step` determines the next role via the moderator
|
|
||||||
2. Agent CLI is spawned with `(thread-id, role)` as positional args
|
|
||||||
3. `workflow-util-agent` (`createAgent`) handles the boilerplate:
|
|
||||||
- Parses argv
|
|
||||||
- Loads `.env` from storage root
|
|
||||||
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
|
||||||
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
|
|
||||||
- Calls the agent's `run` function
|
|
||||||
- Runs two-layer extract on the raw output
|
|
||||||
- Writes `StepNode` to CAS (output + detail + prev link)
|
|
||||||
- Prints the new `StepNode` CAS hash to stdout
|
|
||||||
4. `uwf thread step` reads stdout, updates `threads.yaml` head pointer, re-evaluates moderator for `done`
|
|
||||||
5. Exit 0 = success, non-zero = failure
|
|
||||||
|
|
||||||
Agent resolution priority: `--agent` CLI override → `config.yaml` per-workflow/role override → `config.yaml` `defaultAgent`.
|
### Constraints
|
||||||
|
|
||||||
## Agent output format: frontmatter markdown (RFC #351)
|
- Single `.esm.js` file
|
||||||
|
- No dynamic `import()` in bundles (loader exempt in engine)
|
||||||
|
- Portable bundle static imports are constrained by validation in `@uncaged/workflow-register` (`validateWorkflowBundle`)
|
||||||
|
- XXH64 hash (Crockford Base32) = version ID
|
||||||
|
|
||||||
Agents produce **frontmatter markdown** — YAML frontmatter for structured meta, followed by a markdown body for content:
|
### Why AsyncGenerator?
|
||||||
|
|
||||||
```markdown
|
- Each `yield` lets `workflow-execute` persist state, CAS rows, and enforce pause/abort
|
||||||
---
|
- `return` supplies `WorkflowCompletion`
|
||||||
status: done
|
- Fork replays historical steps into a new thread context
|
||||||
next: reviewer
|
- Bundle does not import the engine — only protocol/runtime types at build time
|
||||||
confidence: 0.9
|
|
||||||
artifacts:
|
|
||||||
- src/auth.ts
|
|
||||||
scope: role
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Fixed the login redirect by updating the auth middleware...
|
|
||||||
```
|
|
||||||
|
|
||||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-util-agent`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
|
|
||||||
|
|
||||||
## Two-layer extract
|
|
||||||
|
|
||||||
Structured output extraction uses a two-layer strategy (`workflow-util-agent`):
|
|
||||||
|
|
||||||
### Layer 1: frontmatter fast path (`frontmatter.ts`)
|
|
||||||
|
|
||||||
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
|
|
||||||
2. Validate required fields (`validateFrontmatter`)
|
|
||||||
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
|
|
||||||
4. `store.put()` the candidate against the role's `meta` schema
|
|
||||||
5. Validate with `json-cas` schema validation
|
|
||||||
6. If valid → return `outputHash` (zero LLM cost)
|
|
||||||
|
|
||||||
### Layer 2: LLM extract fallback (`extract.ts`)
|
|
||||||
|
|
||||||
If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy schema):
|
|
||||||
|
|
||||||
1. Resolve extract model alias from config (`modelOverrides.extract` → `models.extract` → `defaultModel`)
|
|
||||||
2. Call OpenAI-compatible chat completion with JSON mode
|
|
||||||
3. System prompt: "Extract structured data matching this JSON Schema: ..."
|
|
||||||
4. User message: the raw agent output
|
|
||||||
5. Parse response, `store.put()`, validate
|
|
||||||
6. Return `outputHash`
|
|
||||||
|
|
||||||
## Prompt injection
|
|
||||||
|
|
||||||
`workflow-util-agent` prepends two pieces of context to the agent's system prompt:
|
|
||||||
|
|
||||||
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
|
|
||||||
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
|
|
||||||
|
|
||||||
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
|
|
||||||
|
|
||||||
## CAS node types
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: <workflow-schema-hash>
|
|
||||||
payload:
|
|
||||||
name: "solve-issue"
|
|
||||||
description: "End-to-end issue resolution"
|
|
||||||
roles:
|
|
||||||
planner:
|
|
||||||
description: "Creates implementation plan"
|
|
||||||
goal: "You are a planning agent..."
|
|
||||||
capabilities: [planning, issue-analysis]
|
|
||||||
procedure: "Analyze the issue and create a plan."
|
|
||||||
output: "Output the plan summary."
|
|
||||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
|
|
||||||
conditions:
|
|
||||||
notApproved:
|
|
||||||
description: "Reviewer rejected"
|
|
||||||
expression: "steps[-1].output.approved = false"
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
- role: "planner"
|
|
||||||
condition: null
|
|
||||||
```
|
|
||||||
|
|
||||||
### StartNode
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: <start-node-schema-hash>
|
|
||||||
payload:
|
|
||||||
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
|
||||||
prompt: "Fix the login bug..."
|
|
||||||
```
|
|
||||||
|
|
||||||
### StepNode
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: <step-node-schema-hash>
|
|
||||||
payload:
|
|
||||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
|
|
||||||
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
|
|
||||||
role: "developer"
|
|
||||||
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against meta schema)
|
|
||||||
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
|
|
||||||
agent: "uwf-hermes" # agent command used (plain string)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Chain structure
|
|
||||||
|
|
||||||
```
|
|
||||||
threads.yaml: { "01J7K9...4T": "8FWKR3TN5V1QA" }
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
StepNode (step 3)
|
|
||||||
├── start ──→ StartNode
|
|
||||||
│ ├── workflow → Workflow (CAS)
|
|
||||||
│ └── prompt: "Fix..."
|
|
||||||
├── prev ──→ StepNode (step 2)
|
|
||||||
│ ├── prev ──→ StepNode (step 1)
|
|
||||||
│ │ └── prev: null
|
|
||||||
│ └── ...
|
|
||||||
├── role: "reviewer"
|
|
||||||
├── output → CAS({ approved: true })
|
|
||||||
├── detail → CAS(session turns)
|
|
||||||
└── agent: "uwf-hermes"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Storage layout
|
## Storage layout
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.uncaged/workflow/
|
~/.uncaged/workflow/
|
||||||
├── cas/ # json-cas filesystem store (all CAS nodes)
|
├── cas/ # Global content-addressed blobs (see getGlobalCasDir)
|
||||||
├── config.yaml # Provider, model, agent configuration
|
├── bundles/
|
||||||
├── threads.yaml # Active thread head pointers: threadId → CasRef
|
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||||
├── history.jsonl # Archived thread records
|
│ ├── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
|
||||||
├── registry.yaml # Workflow name → CAS hash mapping
|
│ └── C9NMV6V2TQT81/ # Per-hash bundle dir (alongside or instead of loose files)
|
||||||
└── .env # API keys (loaded by dotenv)
|
│ ├── threads.json # Active threads: threadId → { head, start, updatedAt }
|
||||||
|
│ └── history/
|
||||||
|
│ └── 2026-05-09.jsonl # Completed threads (one JSON object per line)
|
||||||
|
├── logs/ # One folder per bundle hash
|
||||||
|
│ └── C9NMV6V2TQT81/
|
||||||
|
│ ├── 01KQXKW…YG.running # Present while worker executes this thread (optional)
|
||||||
|
│ └── 01KQXKW…YG.info.jsonl # Debug log
|
||||||
|
└── workflow.yaml # Registry
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mutable state
|
|
||||||
|
|
||||||
Only three files carry mutable state:
|
|
||||||
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `threads.yaml` | `Record<ThreadId, CasRef>` — maps active thread IDs to head node hash |
|
|
||||||
| `history.jsonl` | Append-only log of completed threads (`thread`, `workflow`, `head`, `completedAt`) |
|
|
||||||
| `registry.yaml` | Workflow name → current CAS hash |
|
|
||||||
|
|
||||||
Everything else is immutable CAS content.
|
|
||||||
|
|
||||||
### ID encoding: Crockford Base32
|
### ID encoding: Crockford Base32
|
||||||
|
|
||||||
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
|
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
|
||||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
- Bundle hash: XXH64 → 13-char
|
||||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
- Thread ID: ULID → 26-char (10 timestamp + 16 random)
|
||||||
|
|
||||||
### Config (`config.yaml`)
|
### Registry (`workflow.yaml`)
|
||||||
|
|
||||||
```yaml
|
Managed by `@uncaged/workflow-register` (`readWorkflowRegistry`, `writeWorkflowRegistry`, …). Shape includes workflow entries and a top-level `config` section used for extract/supervisor model resolution.
|
||||||
providers:
|
|
||||||
openrouter:
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1"
|
|
||||||
apiKey: "sk-..."
|
|
||||||
|
|
||||||
models:
|
### Thread storage (CAS + index)
|
||||||
sonnet:
|
|
||||||
provider: "openrouter"
|
|
||||||
name: "anthropic/claude-sonnet-4"
|
|
||||||
gpt4o-mini:
|
|
||||||
provider: "openai"
|
|
||||||
name: "gpt-4o-mini"
|
|
||||||
|
|
||||||
agents:
|
Thread execution state is a chain of immutable CAS nodes (`StartNode`, `StateNode`, content Merkle blobs). Per bundle:
|
||||||
hermes:
|
|
||||||
command: "uwf-hermes"
|
|
||||||
args: []
|
|
||||||
cursor:
|
|
||||||
command: "uwf-cursor"
|
|
||||||
args: []
|
|
||||||
|
|
||||||
defaultAgent: "hermes"
|
- **`threads.json`** — only in-flight threads (`head`, `start`, `updatedAt`).
|
||||||
agentOverrides:
|
- **`history/{YYYY-MM-DD}.jsonl`** — completed threads (`threadId`, `head`, `start`, `completedAt`).
|
||||||
solve-issue:
|
- **CAS (`cas/`)** — payloads and refs for replay, GC, and fork sharing.
|
||||||
developer: "cursor"
|
|
||||||
|
|
||||||
defaultModel: "sonnet"
|
**`.info.jsonl`** — Structured debug log via `@uncaged/workflow-util` `createLogger`:
|
||||||
modelOverrides:
|
|
||||||
extract: "gpt4o-mini"
|
```jsonc
|
||||||
|
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → code location.
|
||||||
|
|
||||||
|
## Execution model
|
||||||
|
|
||||||
|
- **No daemon.** `uncaged-workflow run <name>` starts a worker process (`workflow-execute` worker entry via `getWorkerHostScriptPath`)
|
||||||
|
- Threads share bundle-scoped workers as implemented in CLI/engine
|
||||||
|
- Pause/resume/abort via engine IPC and pause gate (`createThreadPauseGate`)
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
||||||
Binary: `uwf`
|
| Priority | Command | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
### Thread commands
|
| P1 | `add <name> <file.esm.js>` | Register a bundle |
|
||||||
|
| P1 | `list` | List registered workflows |
|
||||||
| Command | Description |
|
| P1 | `show <name>` | Show workflow details |
|
||||||
|---------|-------------|
|
| P1 | `remove <name>` | Remove a workflow |
|
||||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread (StartNode → CAS, head → threads.yaml). No execution. |
|
| P1 | `run <name> [--prompt] [--max-rounds]` | Start a thread |
|
||||||
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle. |
|
| P1 | `threads [name]` | List threads |
|
||||||
| `uwf thread show <thread-id>` | Show thread head pointer and done status. |
|
| P1 | `thread <id>` | Show thread state |
|
||||||
| `uwf thread list [--all]` | List active threads (`--all` includes archived). |
|
| P1 | `thread rm <id>` | Delete a thread |
|
||||||
| `uwf thread steps <thread-id>` | List all steps in chronological order. |
|
| P1 | `ps` | List running threads |
|
||||||
| `uwf thread read <thread-id> [--quota <chars>] [--before <hash>]` | Render thread as human-readable markdown. |
|
| P1 | `kill <thread-id>` | Terminate a running thread |
|
||||||
| `uwf thread fork <step-hash>` | Fork a thread from a specific CAS node. |
|
| P2 | `history <name>` | Show version history |
|
||||||
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML. |
|
| P2 | `rollback <name> [hash]` | Switch to a previous version |
|
||||||
| `uwf thread kill <thread-id>` | Terminate and archive a thread. |
|
| P2 | `pause <thread-id>` | Pause a running thread |
|
||||||
|
| P2 | `resume <thread-id>` | Resume a paused thread |
|
||||||
### Workflow commands
|
| P3 | `fork <thread-id> [--from-role <role>]` | Fork from historical state |
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uwf workflow put <file.yaml>` | Register a workflow from YAML definition. |
|
|
||||||
| `uwf workflow show <id>` | Show workflow by name or CAS hash. |
|
|
||||||
| `uwf workflow list` | List registered workflows. |
|
|
||||||
|
|
||||||
### CAS commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uwf cas get <hash>` | Read a CAS node. |
|
|
||||||
| `uwf cas put <type-hash> <data>` | Store a node, print its hash. |
|
|
||||||
| `uwf cas has <hash>` | Check if a hash exists. |
|
|
||||||
| `uwf cas refs <hash>` | List direct CAS references. |
|
|
||||||
| `uwf cas walk <hash>` | Recursive traversal from a node. |
|
|
||||||
| `uwf cas reindex` | Rebuild type index from all nodes. |
|
|
||||||
| `uwf cas schema list` | List registered schemas. |
|
|
||||||
| `uwf cas schema get <hash>` | Show a schema by type hash. |
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uwf setup [--provider --base-url --api-key --model --agent]` | Configure provider/model/agent (interactive if no flags). |
|
|
||||||
|
|
||||||
## Toolchain
|
|
||||||
|
|
||||||
| Tool | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| **bun** | Package manager + runtime |
|
|
||||||
| **TypeScript** | Type checking (strict mode) |
|
|
||||||
| **Biome** | Lint + format |
|
|
||||||
| **vitest** | Test runner |
|
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
| Decision | Rationale |
|
| Decision | Rationale |
|
||||||
|----------|-----------|
|
|----------|-----------|
|
||||||
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
|
| **Role = pure data** | Decouples definition from execution; same role with different agents |
|
||||||
| **Stateless single-step CLI** | Each `uwf thread step` is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
|
| **Agent bound at runtime** | `WorkflowDefinition` is reusable; agent choice is deployment concern |
|
||||||
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
|
| **Three-phase context** | Each phase sees only what it needs; types live in `workflow-protocol` |
|
||||||
| **Status-based moderator** | Status-based map routing — `graph[role][status]` lookup against last output. No LLM cost for routing decisions. |
|
| **`WorkflowRuntime.extract` + CAS `contentHash`** | Large agent bodies deduplicated globally; Merkle roots summarize threads |
|
||||||
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
|
| **`workflow-reactor` split** | LLM tool-calling loop isolated from filesystem/registry concerns |
|
||||||
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
|
| **Single-file ESM** | Hash = version, self-contained bundle |
|
||||||
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
|
| **No daemon** | OS handles process lifecycle |
|
||||||
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `json-cas`. No code generation, no runtime library dependency. |
|
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||||
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
|
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||||
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
|
|
||||||
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
|
|
||||||
|
|||||||
@@ -1,779 +0,0 @@
|
|||||||
# Built-in Role Agent 调研
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
实现一个内置的 role agent(暂称 `uwf-builtin`),不依赖 hermes/openclaw 等外部 agent 进程。
|
|
||||||
直接使用 workflow config 中配置的 model,自己实现 agent run loop 和关键 toolkit。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键问题
|
|
||||||
|
|
||||||
### Q1: Agent 接口协议
|
|
||||||
|
|
||||||
现有 agent 是怎么被 CLI 调用的?输入(argv、环境变量)和输出(stdout、CAS)格式是什么?
|
|
||||||
|
|
||||||
**调研要点:**
|
|
||||||
- `cli-workflow` 里 `spawnAgent` 的完整实现
|
|
||||||
- AgentConfig 类型定义
|
|
||||||
- agent 进程的 exit code 约定
|
|
||||||
- 环境变量传递(UWF_STORAGE_ROOT 等)
|
|
||||||
|
|
||||||
**答案:**
|
|
||||||
|
|
||||||
#### 调用链
|
|
||||||
|
|
||||||
`uwf thread step` → `cmdThreadStepOnce` → moderator 求值下一 role → `resolveAgentConfig` → `spawnAgent`。
|
|
||||||
|
|
||||||
#### AgentConfig 类型
|
|
||||||
|
|
||||||
```146:149:packages/workflow-protocol/src/types.ts
|
|
||||||
export type AgentConfig = {
|
|
||||||
command: string;
|
|
||||||
args: string[];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `config.yaml` 的 `agents` 段注册,例如 `hermes: { command: "uwf-hermes", args: [] }`。
|
|
||||||
|
|
||||||
#### spawnAgent 行为
|
|
||||||
|
|
||||||
```627:653:packages/cli-workflow/src/commands/thread.ts
|
|
||||||
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
|
||||||
const argv = [...agent.args, threadId, role];
|
|
||||||
let stdout: string;
|
|
||||||
try {
|
|
||||||
stdout = execFileSync(agent.command, argv, {
|
|
||||||
encoding: "utf8",
|
|
||||||
env: process.env,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// ... stderr 拼进 fail 消息
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
||||||
if (!isCasRef(line)) {
|
|
||||||
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 项目 | 约定 |
|
|
||||||
|------|------|
|
|
||||||
| **argv** | `[...agent.args, <thread-id>, <role>]`,即 `process.argv[2]`=threadId,`process.argv[3]`=role(与 `createAgent` 的 `parseArgv` 一致) |
|
|
||||||
| **stdin** | 忽略 |
|
|
||||||
| **stdout** | 纯文本,**最后一行**必须是新 `StepNode` 的 CAS hash(13 字符 Crockford Base32) |
|
|
||||||
| **stderr** | 失败时 CLI 会附带 stderr;成功时无约定 |
|
|
||||||
| **exit code** | `0` = 成功;非 0 时 `execFileSync` 抛错,step 失败 |
|
|
||||||
| **环境变量** | 继承父进程 `process.env`(含 storage root、API key 等) |
|
|
||||||
| **链头更新** | **不由 agent 负责**;agent 只写 CAS StepNode,CLI 在拿到 stdout hash 后更新 `threads.yaml` |
|
|
||||||
|
|
||||||
Agent 解析优先级(`resolveAgentConfig`):
|
|
||||||
|
|
||||||
1. CLI `--agent` override(整段 command + args 字符串)
|
|
||||||
2. `config.agentOverrides[workflow.name][role]`
|
|
||||||
3. `config.defaultAgent`
|
|
||||||
|
|
||||||
#### 环境变量:Storage Root
|
|
||||||
|
|
||||||
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-util-agent` / `cli-workflow` 一致):
|
|
||||||
|
|
||||||
```33:43:packages/workflow-util-agent/src/storage.ts
|
|
||||||
export function resolveStorageRoot(): string {
|
|
||||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
|
||||||
if (internal !== undefined && internal !== "") {
|
|
||||||
return internal;
|
|
||||||
}
|
|
||||||
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
|
||||||
if (userOverride !== undefined && userOverride !== "") {
|
|
||||||
return userOverride;
|
|
||||||
}
|
|
||||||
return getDefaultStorageRoot();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage root;`createAgent` 内还会 `loadDotenv({ path: getEnvPath(storageRoot) })` 加载 `~/.uncaged/workflow/.env`。
|
|
||||||
|
|
||||||
#### Agent 侧职责(设计文档 + 实现)
|
|
||||||
|
|
||||||
- 读 `threads.yaml` 链头,构建 context,执行 role
|
|
||||||
- 将 `StepNode` 写入 CAS(`output` / `detail` / `agent` / `prev` / `start`)
|
|
||||||
- stdout 打印 step hash
|
|
||||||
- **不**更新 `threads.yaml`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Q2: createAgent 工厂
|
|
||||||
|
|
||||||
workflow-util-agent 的 `createAgent` 做了什么?它的完整生命周期是什么?
|
|
||||||
|
|
||||||
**调研要点:**
|
|
||||||
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
|
|
||||||
- `AgentRunResult` 的完整定义
|
|
||||||
- retry 逻辑(frontmatter 校验失败后的重试机制)
|
|
||||||
- `persistStep` 写入 CAS 的 StepNode 结构
|
|
||||||
|
|
||||||
**答案:**
|
|
||||||
|
|
||||||
#### 类型定义
|
|
||||||
|
|
||||||
```4:35:packages/workflow-util-agent/src/types.ts
|
|
||||||
export type AgentContext = ModeratorContext & {
|
|
||||||
threadId: ThreadId;
|
|
||||||
role: string;
|
|
||||||
store: Store;
|
|
||||||
workflow: WorkflowPayload;
|
|
||||||
outputFormatInstruction: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentRunResult = {
|
|
||||||
output: string;
|
|
||||||
detailHash: CasRef;
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`run(ctx)`**:首次执行,返回原始 agent 文本 `output`、审计用 `detailHash`、用于续聊的 `sessionId`。
|
|
||||||
- **`continue(sessionId, message, store)`**:在同一 session 上追加用户消息(用于 frontmatter 纠错),再次返回 `AgentRunResult`。
|
|
||||||
|
|
||||||
`createAgent(options)` 返回 `() => Promise<void>`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。
|
|
||||||
|
|
||||||
#### 生命周期(按执行顺序)
|
|
||||||
|
|
||||||
```101:152:packages/workflow-util-agent/src/run.ts
|
|
||||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
|
||||||
return async function main(): Promise<void> {
|
|
||||||
const { threadId, role } = parseArgv(process.argv);
|
|
||||||
const storageRoot = resolveStorageRoot();
|
|
||||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
|
||||||
|
|
||||||
const ctx = await buildContextWithMeta(threadId, role);
|
|
||||||
// 1. 校验 role 存在
|
|
||||||
// 2. 从 CAS 取 frontmatter JSON Schema → buildOutputFormatInstruction → ctx.outputFormatInstruction
|
|
||||||
|
|
||||||
let agentResult = await options.run(ctx);
|
|
||||||
|
|
||||||
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...";
|
|
||||||
agentResult = await options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store);
|
|
||||||
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outputHash === null) { fail(...); }
|
|
||||||
|
|
||||||
const stepHash = await persistStep({ ctx, outputHash, detailHash: agentResult.detailHash, agentName });
|
|
||||||
process.stdout.write(`${stepHash}\n`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 阶段 | 行为 |
|
|
||||||
|------|------|
|
|
||||||
| 解析 argv | `argv[2]=threadId`, `argv[3]=role`,缺失则 `stderr` + `exit(1)` |
|
|
||||||
| Context | `buildContextWithMeta` + 可选 `outputFormatInstruction` |
|
|
||||||
| Run | `options.run(ctx)` |
|
|
||||||
| Extract | **仅** `tryFrontmatterFastPath`(见 Q4);**不**调用 `extract()` LLM fallback |
|
|
||||||
| Retry | 最多 `MAX_FRONTMATTER_RETRIES = 2` 次 `continue` + 再试 fast-path |
|
|
||||||
| Persist | `persistStep` → `writeStepNode` |
|
|
||||||
| 输出 | stdout 一行 step CAS hash |
|
|
||||||
|
|
||||||
#### StepNode 写入结构
|
|
||||||
|
|
||||||
```44:68:packages/workflow-util-agent/src/run.ts
|
|
||||||
async function writeStepNode(options: {
|
|
||||||
store: AgentStore["store"];
|
|
||||||
schemas: AgentStore["schemas"];
|
|
||||||
startHash: CasRef;
|
|
||||||
prevHash: CasRef | null;
|
|
||||||
role: string;
|
|
||||||
outputHash: CasRef;
|
|
||||||
detailHash: CasRef;
|
|
||||||
agentName: string;
|
|
||||||
}): Promise<CasRef> {
|
|
||||||
const payload: StepNodePayload = {
|
|
||||||
start: options.startHash,
|
|
||||||
prev: options.prevHash,
|
|
||||||
role: options.role,
|
|
||||||
output: options.outputHash,
|
|
||||||
detail: options.detailHash,
|
|
||||||
agent: options.agentName,
|
|
||||||
};
|
|
||||||
// store.put(stepNode schema) + validate
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`agentName` 经 `agentLabel(name)` 规范化:已有 `uwf-` 前缀则原样,否则加 `uwf-`(如 `hermes` → `uwf-hermes`)。
|
|
||||||
|
|
||||||
`prevHash`:若链头仍是 `StartNode` 则为 `null`,否则为当前 head step hash。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Q3: Context Builder
|
|
||||||
|
|
||||||
`buildContextWithMeta` 构建了什么上下文给 agent?
|
|
||||||
|
|
||||||
**调研要点:**
|
|
||||||
- `AgentContext` 完整类型定义(所有字段)
|
|
||||||
- context 构建过程(CAS chain walk)
|
|
||||||
- `outputFormatInstruction` 怎么生成的
|
|
||||||
- role definition 怎么获取(从 workflow YAML)
|
|
||||||
|
|
||||||
**答案:**
|
|
||||||
|
|
||||||
#### AgentContext 字段
|
|
||||||
|
|
||||||
继承 `ModeratorContext`:
|
|
||||||
|
|
||||||
```60:68:packages/workflow-protocol/src/types.ts
|
|
||||||
export type ModeratorContext = {
|
|
||||||
start: StartNodePayload;
|
|
||||||
steps: StepContext[];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
```48:51:packages/workflow-protocol/src/types.ts
|
|
||||||
export type StartNodePayload = {
|
|
||||||
workflow: CasRef;
|
|
||||||
prompt: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
```61:63:packages/workflow-protocol/src/types.ts
|
|
||||||
export type StepContext = Omit<StepRecord, "output"> & {
|
|
||||||
output: unknown;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
`AgentContext` 额外字段:
|
|
||||||
|
|
||||||
| 字段 | 类型 | 含义 |
|
|
||||||
|------|------|------|
|
|
||||||
| `threadId` | `ThreadId` | 当前线程 |
|
|
||||||
| `role` | `string` | 本步要执行的角色名 |
|
|
||||||
| `store` | `Store` | CAS store(读写节点) |
|
|
||||||
| `workflow` | `WorkflowPayload` | 已从 CAS 加载的 workflow 定义 |
|
|
||||||
| `outputFormatInstruction` | `string` | 由 `createAgent` 根据 role 的 frontmatter schema 生成;`buildContext*` 初始为 `""` |
|
|
||||||
|
|
||||||
`buildContextWithMeta` 还返回 `meta`:
|
|
||||||
|
|
||||||
```148:154:packages/workflow-util-agent/src/context.ts
|
|
||||||
export type BuildContextMeta = {
|
|
||||||
storageRoot: string;
|
|
||||||
store: Store;
|
|
||||||
schemas: AgentStore["schemas"];
|
|
||||||
headHash: CasRef;
|
|
||||||
chain: ChainState;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
#### CAS chain walk
|
|
||||||
|
|
||||||
1. 从 `threads.yaml[threadId]` 取 `headHash`
|
|
||||||
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
|
|
||||||
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
|
|
||||||
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
|
|
||||||
|
|
||||||
#### Role definition 来源
|
|
||||||
|
|
||||||
- 作者写在 workflow YAML 的 `roles.<name>`(`goal`, `capabilities`, `procedure`, `output`, `frontmatter` 等)
|
|
||||||
- `uwf workflow put` 时 `frontmatter` 内联 JSON Schema 经 `putSchema` 存入 CAS,workflow 里存的是 **CasRef**
|
|
||||||
- Agent 运行时:`ctx.workflow.roles[ctx.role]` → `RoleDefinition`
|
|
||||||
|
|
||||||
#### outputFormatInstruction
|
|
||||||
|
|
||||||
在 `createAgent` 中,若 `getSchema(store, roleDef.frontmatter)` 非空,则:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
|
|
||||||
```
|
|
||||||
|
|
||||||
`buildOutputFormatInstruction` 根据 JSON Schema 的 `properties` 生成「必须以 `---` YAML frontmatter 开头」的说明和示例字段列表(见 `build-output-format-instruction.ts`)。
|
|
||||||
|
|
||||||
各 agent 实现(Hermes / Claude Code)在组装 prompt 时把该块放在最前,再接 `buildRolePrompt(roleDef)`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Q4: Extract Pipeline
|
|
||||||
|
|
||||||
agent 输出怎么被处理成结构化数据?
|
|
||||||
|
|
||||||
**调研要点:**
|
|
||||||
- frontmatter fast-path 的完整逻辑
|
|
||||||
- LLM extract fallback 的实现(`extract.ts`)
|
|
||||||
- frontmatter schema 从哪里来(role 定义里的 `frontmatter` 字段)
|
|
||||||
- 校验失败时的 correction prompt 是什么
|
|
||||||
|
|
||||||
**答案:**
|
|
||||||
|
|
||||||
#### Schema 来源
|
|
||||||
|
|
||||||
Workflow YAML 中每个 role 的 `frontmatter:` 段是 JSON Schema 对象;注册时:
|
|
||||||
|
|
||||||
```66:76:packages/cli-workflow/src/commands/workflow.ts
|
|
||||||
async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef> {
|
|
||||||
// 校验为 JSON Schema → putSchema → 返回 CasRef
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
运行时 `roleDef.frontmatter` 即该 schema 的 CAS hash;structured `output` 节点用**同一 schema** 写入 CAS。
|
|
||||||
|
|
||||||
#### Frontmatter fast-path(createAgent 实际使用的路径)
|
|
||||||
|
|
||||||
```148:195:packages/workflow-util-agent/src/frontmatter.ts
|
|
||||||
export async function tryFrontmatterFastPath(
|
|
||||||
raw: string,
|
|
||||||
outputSchema: CasRef,
|
|
||||||
store: Store,
|
|
||||||
): Promise<FrontmatterFastPathResult | null>
|
|
||||||
```
|
|
||||||
|
|
||||||
流程:
|
|
||||||
|
|
||||||
1. `parseFrontmatterMarkdown(raw)` → 标准 agent 字段(`status`, `next`, `confidence`, `artifacts`, `scope`)+ body
|
|
||||||
2. `validateFrontmatter` 失败 → `null`
|
|
||||||
3. `getSchema(store, outputSchema)` + `extractSchemaFields` 得到 role 需要的属性名
|
|
||||||
4. `buildCandidate`:从标准 frontmatter + YAML 原始字段拼出符合 schema 的对象
|
|
||||||
5. `store.put(outputSchema, candidate)` + `validate` → 成功则 `{ body, outputHash }`
|
|
||||||
|
|
||||||
**永不抛错**,失败返回 `null`。
|
|
||||||
|
|
||||||
#### LLM extract fallback(已实现但未接入 createAgent)
|
|
||||||
|
|
||||||
```135:181:packages/workflow-util-agent/src/extract.ts
|
|
||||||
export async function extract(
|
|
||||||
rawOutput: string,
|
|
||||||
outputSchema: CasRef,
|
|
||||||
config: WorkflowConfig,
|
|
||||||
): Promise<ExtractResult>
|
|
||||||
```
|
|
||||||
|
|
||||||
- 模型:`resolveExtractModelAlias(config)` → `modelOverrides.extract` → `models.extract` → `models.default` → `defaultModel`
|
|
||||||
- HTTP:`POST {baseUrl}/chat/completions`,`response_format: { type: "json_object" }`
|
|
||||||
- System:要求按 JSON Schema 从 agent 输出提取单个 JSON 对象
|
|
||||||
- 校验通过后 `store.put(outputSchema, structured)`
|
|
||||||
|
|
||||||
**重要:`createAgent` 当前未调用 `extract()`**。fast-path 失败且 2 次 `continue` 仍失败则直接 `fail()`。builtin agent 若希望无 frontmatter 也能跑,需在 kit 或 builtin 层显式接入 `extract()`。
|
|
||||||
|
|
||||||
#### Correction prompt(retry)
|
|
||||||
|
|
||||||
```125:128:packages/workflow-util-agent/src/run.ts
|
|
||||||
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.";
|
|
||||||
```
|
|
||||||
|
|
||||||
通过 `options.continue(sessionId, correctionMessage, store)` 发给外部 agent;builtin 需在自有 message 历史里 append 同等语义的 user 消息。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Q5: Model 配置与 LLM 调用
|
|
||||||
|
|
||||||
workflow 怎么配置和使用 model?
|
|
||||||
|
|
||||||
**调研要点:**
|
|
||||||
- `WorkflowConfig` 中 providers/models/defaultModel/modelOverrides 的完整定义
|
|
||||||
- `resolveModel` 函数的实现
|
|
||||||
- `chatCompletionText` 的实现(OpenAI 兼容 HTTP 客户端)
|
|
||||||
- 有没有 streaming 支持?tool calling 支持?
|
|
||||||
|
|
||||||
**答案:**
|
|
||||||
|
|
||||||
#### WorkflowConfig
|
|
||||||
|
|
||||||
```136:160:packages/workflow-protocol/src/types.ts
|
|
||||||
export type ProviderConfig = {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ModelConfig = {
|
|
||||||
provider: ProviderAlias;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowConfig = {
|
|
||||||
providers: Record<ProviderAlias, ProviderConfig>;
|
|
||||||
models: Record<ModelAlias, ModelConfig>;
|
|
||||||
agents: Record<AgentAlias, AgentConfig>;
|
|
||||||
defaultAgent: AgentAlias;
|
|
||||||
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
|
||||||
defaultModel: ModelAlias;
|
|
||||||
modelOverrides: Record<Scenario, ModelAlias> | null;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
示例见 `docs/architecture.md`(`providers` / `models` / `defaultModel` / `modelOverrides.extract`)。
|
|
||||||
|
|
||||||
#### resolveModel
|
|
||||||
|
|
||||||
```32:50:packages/workflow-util-agent/src/extract.ts
|
|
||||||
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
|
||||||
const modelEntry = config.models[alias];
|
|
||||||
const providerEntry = config.providers[modelEntry.provider];
|
|
||||||
const apiKey = providerEntry.apiKey;
|
|
||||||
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`ResolvedLlmProvider = { baseUrl, apiKey, model }`。
|
|
||||||
|
|
||||||
Extract 专用别名解析:
|
|
||||||
|
|
||||||
```18:30:packages/workflow-util-agent/src/extract.ts
|
|
||||||
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
|
||||||
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**尚无** `modelOverrides` 按 role/workflow 解析 agent 主模型的函数;builtin 首版可用 `config.defaultModel`,扩展时可加 `modelOverrides.agent` 或与 `agentOverrides` 对称的表。
|
|
||||||
|
|
||||||
#### chatCompletionText
|
|
||||||
|
|
||||||
```87:124:packages/workflow-util-agent/src/extract.ts
|
|
||||||
async function chatCompletionText(
|
|
||||||
provider: ResolvedLlmProvider,
|
|
||||||
messages: Array<{ role: "system" | "user"; content: string }>,
|
|
||||||
): Promise<string>
|
|
||||||
```
|
|
||||||
|
|
||||||
| 能力 | 现状 |
|
|
||||||
|------|------|
|
|
||||||
| 协议 | OpenAI 兼容 `POST /chat/completions` |
|
|
||||||
| Streaming | **无**(一次性 `response.text()`) |
|
|
||||||
| Tool calling | **无**(无 `tools` / `tool_calls` 字段) |
|
|
||||||
| 多模态 | **无**(仅 text `content`) |
|
|
||||||
| Extract 专用 | `response_format: { type: "json_object" }` |
|
|
||||||
|
|
||||||
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-util-agent` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Q6: Hermes Agent 参考实现
|
|
||||||
|
|
||||||
`uwf-hermes` 是怎么实现 `run` 和 `continue` 的?
|
|
||||||
|
|
||||||
**调研要点:**
|
|
||||||
- prompt 怎么组装的(outputFormatInstruction + rolePrompt + task + history)
|
|
||||||
- hermes CLI 的调用参数
|
|
||||||
- session management(resume)
|
|
||||||
- 输出怎么捕获
|
|
||||||
|
|
||||||
**答案:**
|
|
||||||
|
|
||||||
#### Prompt 组装
|
|
||||||
|
|
||||||
```40:53:packages/workflow-agent-hermes/src/hermes.ts
|
|
||||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
|
||||||
const roleDef = ctx.workflow.roles[ctx.role];
|
|
||||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (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");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`buildRolePrompt` 生成 `## Goal` / `## Capabilities` / `## Prepare`(含 `generateCliReference()`)/ `## Procedure` / `## Output`。
|
|
||||||
|
|
||||||
`buildHistorySummary`:每步 `role`、`JSON.stringify(step.output)`、`agent`。
|
|
||||||
|
|
||||||
Hermes 把**整段 prompt 作为单条 user 消息**传给 `hermes chat -q`(无独立 system channel)。
|
|
||||||
|
|
||||||
#### Hermes CLI 参数
|
|
||||||
|
|
||||||
首次:
|
|
||||||
|
|
||||||
```88:97:packages/workflow-agent-hermes/src/hermes.ts
|
|
||||||
spawnHermes(["chat", "-q", prompt, "--yolo", "--max-turns", "90", "--quiet"]);
|
|
||||||
```
|
|
||||||
|
|
||||||
续聊:
|
|
||||||
|
|
||||||
```100:114:packages/workflow-agent-hermes/src/hermes.ts
|
|
||||||
spawnHermes(["chat", "--resume", sessionId, "-q", message, "--yolo", "--max-turns", "90", "--quiet"]);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Session
|
|
||||||
|
|
||||||
- stdout/stderr 中解析 `session_id: <id>`(`parseSessionIdFromStdout`)
|
|
||||||
- 会话文件:`~/.hermes/sessions/session_<id>.json`
|
|
||||||
- `loadHermesSession` → `storeHermesSessionDetail`:每 assistant/tool 消息写成 CAS turn 节点,汇总为 `detail`;**output 文本** = 最后一条非空 `assistant` 的 `content`
|
|
||||||
|
|
||||||
#### 与 createAgent 的衔接
|
|
||||||
|
|
||||||
```157:164:packages/workflow-agent-hermes/src/hermes.ts
|
|
||||||
export function createHermesAgent(): () => Promise<void> {
|
|
||||||
return createAgent({ name: "hermes", run: runHermes, continue: continueHermes });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`uwf-hermes` 入口:`createHermesAgent()` 即 main。
|
|
||||||
|
|
||||||
Claude Code 包(`workflow-agent-claude-code`)结构相同:`buildClaudeCodePrompt` 同构,`claude -p` + `--resume` + JSON stdout 解析。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Q7: Toolkit 需求分析
|
|
||||||
|
|
||||||
要实现一个自给自足的 agent,最少需要哪些 tool?
|
|
||||||
|
|
||||||
**调研要点:**
|
|
||||||
- 现有 workflow example(solve-issue.yaml)里 role 都做什么任务
|
|
||||||
- hermes agent 在 workflow 场景下常用哪些 tool
|
|
||||||
- 哪些 tool 是 agent loop 必须的(如 file read/write、shell exec、web fetch)
|
|
||||||
|
|
||||||
**答案:**
|
|
||||||
|
|
||||||
#### solve-issue.yaml 角色能力
|
|
||||||
|
|
||||||
| Role | capabilities | 隐含需求 |
|
|
||||||
|------|----------------|----------|
|
|
||||||
| planner | issue-analysis, planning | 读上下文/仓库、总结,通常不需写代码 |
|
|
||||||
| developer | file-edit, shell, testing | **读文件、写文件、执行命令** |
|
|
||||||
| reviewer | code-review, static-analysis | 读 diff/文件、静态分析(可读+可选 shell) |
|
|
||||||
|
|
||||||
#### Hermes 侧
|
|
||||||
|
|
||||||
Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes 项目定义,workflow 不配置。从 session JSON 可见 `tool_calls` 被记入 detail,常见包括文件与 shell 类工具。
|
|
||||||
|
|
||||||
#### Builtin 最小 toolkit 建议
|
|
||||||
|
|
||||||
| 优先级 | Tool | 用途 |
|
|
||||||
|--------|------|------|
|
|
||||||
| P0 | `read_file` | 读仓库/配置/issue 上下文 |
|
|
||||||
| P0 | `write_file` / `edit_file` | developer 改代码 |
|
|
||||||
| P0 | `run_command` | 测试、构建、git(需 cwd + timeout + 输出截断) |
|
|
||||||
| P1 | `list_dir` / `glob` | 导航代码库 |
|
|
||||||
| P1 | `grep` | 搜索符号/引用 |
|
|
||||||
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
|
|
||||||
|
|
||||||
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
|
|
||||||
|
|
||||||
#### Agent loop 必须能力
|
|
||||||
|
|
||||||
1. 多轮 LLM 调用 + **OpenAI-style tool_calls** 解析与执行
|
|
||||||
2. 将 tool 结果 append 回 messages
|
|
||||||
3. 终止条件:模型不再请求 tool,或达到 `maxTurns`
|
|
||||||
4. 最终响应须含合法 YAML frontmatter(满足 Q4),供 `createAgent` fast-path
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 方案草案
|
|
||||||
|
|
||||||
(调研完成后基于以上答案撰写)
|
|
||||||
|
|
||||||
### 架构设计
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph cli ["cli-workflow"]
|
|
||||||
Step["uwf thread step"]
|
|
||||||
Spawn["spawnAgent(uwf-builtin, threadId, role)"]
|
|
||||||
Step --> Spawn
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph builtin_pkg ["@uncaged/workflow-agent-builtin"]
|
|
||||||
Main["createBuiltinAgent() = createAgent({...})"]
|
|
||||||
Prompt["buildBuiltinPrompt(ctx)"]
|
|
||||||
Loop["runBuiltinLoop(provider, messages, tools)"]
|
|
||||||
Tools["Toolkit: read/write/exec/..."]
|
|
||||||
Detail["storeBuiltinDetail(turns)"]
|
|
||||||
Main --> Prompt
|
|
||||||
Main --> Loop
|
|
||||||
Loop --> Tools
|
|
||||||
Loop --> Detail
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph kit ["workflow-util-agent"]
|
|
||||||
Ctx["buildContextWithMeta"]
|
|
||||||
FM["tryFrontmatterFastPath"]
|
|
||||||
Persist["persistStep"]
|
|
||||||
Ctx --> Main
|
|
||||||
Main --> FM
|
|
||||||
FM --> Persist
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph cas ["CAS / config"]
|
|
||||||
Config["config.yaml models/providers"]
|
|
||||||
CAS["cas/ + threads.yaml"]
|
|
||||||
end
|
|
||||||
|
|
||||||
Spawn --> Main
|
|
||||||
Config --> Loop
|
|
||||||
CAS --> Ctx
|
|
||||||
Persist --> CAS
|
|
||||||
Spawn -->|"stdout: step hash"| Step
|
|
||||||
```
|
|
||||||
|
|
||||||
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
|
|
||||||
|
|
||||||
**分层**:
|
|
||||||
|
|
||||||
| 层 | 职责 |
|
|
||||||
|----|------|
|
|
||||||
| `createAgent`(kit) | argv、context、frontmatter extract、StepNode、stdout 协议 — **不变** |
|
|
||||||
| `builtin/agent.ts` | `run` / `continue` 实现 |
|
|
||||||
| `builtin/llm.ts` | OpenAI 兼容 chat + tools(可后续抽到 kit) |
|
|
||||||
| `builtin/tools/*.ts` | 各 tool 的 JSON Schema + handler |
|
|
||||||
| `builtin/prompt.ts` | 复用 Hermes 的 prompt 拼接逻辑(或抽到 kit 的 `buildAgentPrompt`) |
|
|
||||||
| `builtin/detail.ts` | 类似 Hermes:每轮 assistant/tool 写入 CAS detail |
|
|
||||||
|
|
||||||
**配置集成**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
agents:
|
|
||||||
builtin:
|
|
||||||
command: "uwf-builtin"
|
|
||||||
args: []
|
|
||||||
defaultAgent: "builtin" # 或 agentOverrides 按 role 指定
|
|
||||||
```
|
|
||||||
|
|
||||||
模型:首版 `resolveModel(config, config.defaultModel)`;后续可增加 `modelOverrides.agent` 或 per-role 映射。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Agent Run Loop
|
|
||||||
|
|
||||||
伪代码(单次 `run(ctx)`):
|
|
||||||
|
|
||||||
```
|
|
||||||
1. provider ← resolveModel(loadWorkflowConfig(), defaultModel)
|
|
||||||
2. system ← buildBuiltinPrompt(ctx) // outputFormatInstruction + buildRolePrompt + Task + History
|
|
||||||
3. messages ← [{ role: "system", content: system }]
|
|
||||||
4. sessionId ← newULID() // 内存或临时目录,供 continue 使用
|
|
||||||
5. turns ← []
|
|
||||||
|
|
||||||
6. for turn in 1..MAX_TURNS:
|
|
||||||
response ← chatCompletionWithTools(provider, messages, TOOL_DEFINITIONS)
|
|
||||||
record assistant message + tool_calls in turns
|
|
||||||
|
|
||||||
if response has no tool_calls:
|
|
||||||
finalText ← response.content
|
|
||||||
break
|
|
||||||
|
|
||||||
for each tool_call:
|
|
||||||
result ← executeTool(tool_call, { cwd: process.cwd() })
|
|
||||||
messages.push tool result
|
|
||||||
record in turns
|
|
||||||
|
|
||||||
7. if no finalText with valid frontmatter after loop:
|
|
||||||
optionally one-shot "finalize" message without tools
|
|
||||||
|
|
||||||
8. detailHash ← storeBuiltinDetail(store, sessionId, turns, metadata)
|
|
||||||
9. return { output: finalText, detailHash, sessionId }
|
|
||||||
```
|
|
||||||
|
|
||||||
**`continue(sessionId, message, store)`**:
|
|
||||||
|
|
||||||
- 从内存/磁盘恢复 `messages` + `turns`
|
|
||||||
- `messages.push({ role: "user", content: message })`(correction 或续聊)
|
|
||||||
- 从步骤 6 继续,步数上限可单独设小一点(如 3)
|
|
||||||
- 返回新的 `AgentRunResult`
|
|
||||||
|
|
||||||
**与 frontmatter 的配合**:
|
|
||||||
|
|
||||||
- system prompt 已含 `outputFormatInstruction`;最后一轮可强制 user:`Now output your final answer with YAML frontmatter only if you have not yet.`
|
|
||||||
- 仍依赖 `createAgent` 的 fast-path + 最多 2 次 continue
|
|
||||||
|
|
||||||
**安全**:
|
|
||||||
|
|
||||||
- `run_command`:白名单或需 `UWF_BUILTIN_ALLOW_SHELL=1`,默认工作区限定在 `process.cwd()` 或 `start` 中将来扩展的 `workspace` 字段
|
|
||||||
- 路径:禁止 `..` 逃逸出 workspace root
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Toolkit 设计
|
|
||||||
|
|
||||||
统一注册表:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type BuiltinTool = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
parameters: JSONSchema; // object type
|
|
||||||
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToolContext = {
|
|
||||||
cwd: string;
|
|
||||||
storageRoot: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
| Tool name | OpenAI function | 行为摘要 |
|
|
||||||
|-----------|-----------------|----------|
|
|
||||||
| `read_file` | `read_file` | `{ path }` → UTF-8 文本,大小上限 |
|
|
||||||
| `write_file` | `write_file` | `{ path, content }` → 写盘,返回确认 |
|
|
||||||
| `edit_file` | 可选 | search/replace 块,减少 token |
|
|
||||||
| `run_command` | `run_command` | `{ command, cwd? }` → stdout/stderr 截断 |
|
|
||||||
| `list_dir` | `list_dir` | `{ path }` → 条目列表 |
|
|
||||||
| `grep` | `grep` | `{ pattern, path? }` → 匹配行 |
|
|
||||||
|
|
||||||
**LLM 请求形状**(扩展 extract 客户端):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model": "...",
|
|
||||||
"messages": [...],
|
|
||||||
"tools": [{ "type": "function", "function": { "name", "description", "parameters" } }],
|
|
||||||
"tool_choice": "auto"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
解析 `choices[0].message.tool_calls`,执行后以 `{ role: "tool", tool_call_id, content }` 回传。
|
|
||||||
|
|
||||||
**不提供** streaming 首版;detail CAS 记录每轮 tool 名/参数/结果摘要供 `uwf thread step-details` 调试。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 与现有架构的集成
|
|
||||||
|
|
||||||
| 集成点 | 方式 |
|
|
||||||
|--------|------|
|
|
||||||
| CLI 协议 | 实现标准 agent CLI:`uwf-builtin <thread-id> <role>`,stdout 一行 step hash,exit 0/1 |
|
|
||||||
| 工厂 | `export function createBuiltinAgent()` → `createAgent({ name: "builtin", run, continue })` |
|
|
||||||
| Context / Prompt | 复用 `buildContextWithMeta`、`buildRolePrompt`、`buildOutputFormatInstruction`;prompt 布局对齐 `buildHermesPrompt` |
|
|
||||||
| 结构化输出 | 优先 YAML frontmatter fast-path;可选后续在 `createAgent` 增加 `extract()` fallback 开关 |
|
|
||||||
| 配置 | `config.yaml` 增加 `agents.builtin`;`uwf setup` 可选默认 agent |
|
|
||||||
| 存储 | `resolveStorageRoot()` + `loadWorkflowConfig` + `getEnvPath`;与 Hermes 相同,**不**改 `threads.yaml` 写入方 |
|
|
||||||
| 测试 | 单元测试:tool handlers、prompt 组装、mock LLM tool loop;集成测试:临时 storage root + fake provider |
|
|
||||||
| 发布 | 新包 `@uncaged/workflow-agent-builtin`,bin `uwf-builtin`,加入 `scripts/publish-all.mjs` |
|
|
||||||
|
|
||||||
**明确不做**:
|
|
||||||
|
|
||||||
- 不替代 moderator / 不在 agent 内调用 `uwf thread step`
|
|
||||||
- 不依赖 Hermes/OpenClaw/Claude Code 二进制
|
|
||||||
- 首版不实现 streaming、不实现 MCP
|
|
||||||
|
|
||||||
**建议实现顺序**:
|
|
||||||
|
|
||||||
1. `llm.ts`:tool calling HTTP 客户端 + 单测
|
|
||||||
2. P0 tools + `runBuiltinLoop`
|
|
||||||
3. `createBuiltinAgent` + detail CAS
|
|
||||||
4. `config` / docs / `examples` 可选 `agentOverrides` 演示
|
|
||||||
5. (可选)`createAgent` 接入 `extract()` fallback
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Issue #418: ACP session/resume 返回空文本
|
|
||||||
|
|
||||||
## 调研日期: 2026-05-23
|
|
||||||
|
|
||||||
## 根因
|
|
||||||
|
|
||||||
`session/resume` 在 restore 路径下 `_make_agent()` 失败,异常被静默吞掉。
|
|
||||||
|
|
||||||
### 完整调用链
|
|
||||||
|
|
||||||
```
|
|
||||||
resume_session(sid)
|
|
||||||
→ update_cwd(sid)
|
|
||||||
→ get_session(sid) → _restore(sid)
|
|
||||||
→ _make_agent()
|
|
||||||
→ resolve_runtime_provider("custom") 失败(line 548-561)
|
|
||||||
→ AIAgent() 抛出 "No LLM provider configured"(line 564)
|
|
||||||
→ except Exception 静默吞掉(line 482-484)→ return None
|
|
||||||
→ return None
|
|
||||||
→ state is None → fallback: create_session()(新 sid,无历史)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 关键代码位置(acp_adapter/session.py)
|
|
||||||
|
|
||||||
- `_restore()` line 426-498: 从 DB 恢复 session,但 except 太宽泛
|
|
||||||
- `_make_agent()` line 520-568: provider 解析在 restore 路径下不完整
|
|
||||||
- Line 548-561: `resolve_runtime_provider("custom")` 失败后,`base_url` 虽然从 DB 取到了但没传给 AIAgent
|
|
||||||
|
|
||||||
### 实测行为
|
|
||||||
|
|
||||||
1. Phase 1: `session/new` + `prompt` → 正常,有 `agent_message_chunk`
|
|
||||||
2. Phase 2: `session/resume` + `prompt`
|
|
||||||
- resume 返回成功,但 `available_commands_update` 里 sessionId 是新的(create_session fallback)
|
|
||||||
- 用原始 sid 发 prompt → `stopReason: "refusal"`(session 不在内存中)
|
|
||||||
- 用新 sid 发 prompt → 能跑但无历史(agent 回答"不知道 secret code")
|
|
||||||
|
|
||||||
### 验证脚本
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 直接调用 _restore 验证
|
|
||||||
cd ~/.hermes/hermes-agent
|
|
||||||
python3 -c "
|
|
||||||
import sys; sys.path.insert(0, '.')
|
|
||||||
from acp_adapter.session import SessionManager
|
|
||||||
sm = SessionManager()
|
|
||||||
result = sm._restore('SESSION_ID_HERE')
|
|
||||||
print(result) # None — _make_agent 抛异常被吞掉
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 两个 bug
|
|
||||||
|
|
||||||
1. **`_make_agent` provider fallback 不完整**: restore 时 DB 里有 `base_url` 和 `api_mode`,但 `resolve_runtime_provider` 失败后这些值没被正确传递给 AIAgent
|
|
||||||
2. **`_restore` 的 except 太宽泛**: 静默吞掉所有异常,连 warning 都只在 debug 级别,导致 resume 失败完全无感知
|
|
||||||
|
|
||||||
### Hermes 版本
|
|
||||||
|
|
||||||
- v0.10.0 (2026.4.16) — 初始测试
|
|
||||||
- v0.14.0 (2026.5.16) — 更新后重新测试,bug 仍在
|
|
||||||
- 代码路径: ~/.hermes/hermes-agent/acp_adapter/session.py
|
|
||||||
|
|
||||||
### v0.14.0 测试结果 (2026-05-23)
|
|
||||||
|
|
||||||
- `_restore` 仍因 `custom` provider 解析失败返回 None
|
|
||||||
- 日志更清晰了:`WARNING: Failed to recreate agent for ACP session ...`
|
|
||||||
- resume fallback 创建新 session(新 sid),但 agent 居然能回答之前的问题(可能通过 memory/session search)
|
|
||||||
- 核心问题不变:sessionId 变了,client 用旧 sid 发 prompt → refusal
|
|
||||||
|
|
||||||
### 上游 Issue
|
|
||||||
|
|
||||||
- https://github.com/NousResearch/hermes-agent/issues/13489 — 已评论根因分析
|
|
||||||
- https://github.com/NousResearch/hermes-agent/issues/8083 — resume 静默创建新 session
|
|
||||||
- https://github.com/NousResearch/hermes-agent/issues/18452 — _make_agent fallback 不完整
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
description: Ban dynamic import() in production code — use static imports instead
|
|
||||||
globs: packages/*/src/**/*.ts
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# No Dynamic Import in Production Code
|
|
||||||
|
|
||||||
## Rule
|
|
||||||
|
|
||||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
|
||||||
Always use static top-level `import` statements.
|
|
||||||
|
|
||||||
## Exception (must include a comment explaining why)
|
|
||||||
|
|
||||||
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
|
||||||
|
|
||||||
When suppressing, add a comment directly above:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Dynamic import required: user bundle path resolved at runtime
|
|
||||||
const mod = await import(bundlePath);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Files
|
|
||||||
|
|
||||||
Test files (`__tests__/**`) are exempt.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,387 +0,0 @@
|
|||||||
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
|
|
||||||
|
|
||||||
**日期:** 2026-05-18
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
|
|
||||||
|
|
||||||
| 包 | npm name | 职责 |
|
|
||||||
|---|---|---|
|
|
||||||
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
|
|
||||||
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
|
|
||||||
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
|
|
||||||
|
|
||||||
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、`workflow-template-document`
|
|
||||||
|
|
||||||
### Thread 启动输入
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/types.ts
|
|
||||||
type DocumentStartInput = {
|
|
||||||
prompt: string; // 用户指令
|
|
||||||
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
|
|
||||||
|
|
||||||
### 角色与 Meta
|
|
||||||
|
|
||||||
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const writerMetaSchema = z.discriminatedUnion("mode", [
|
|
||||||
z.object({
|
|
||||||
mode: z.literal("generate"),
|
|
||||||
outputDocx: z.string(), // 生成产物绝对路径
|
|
||||||
sourceDocx: z.null(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
mode: z.literal("edit"),
|
|
||||||
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
|
|
||||||
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
type WriterMeta = z.infer<typeof writerMetaSchema>;
|
|
||||||
|
|
||||||
// differ:仅编辑模式执行
|
|
||||||
const differMetaSchema = z.object({
|
|
||||||
sourceDocx: z.string(),
|
|
||||||
modifiedDocx: z.string(),
|
|
||||||
diffDocx: z.string(),
|
|
||||||
});
|
|
||||||
type DifferMeta = z.infer<typeof differMetaSchema>;
|
|
||||||
```
|
|
||||||
|
|
||||||
两个角色的 `systemPrompt` 均为 `""`。
|
|
||||||
|
|
||||||
### 调度表
|
|
||||||
|
|
||||||
```
|
|
||||||
START → writer ──(mode = "edit")──→ differ → END
|
|
||||||
↘(mode = "generate")→ END
|
|
||||||
```
|
|
||||||
|
|
||||||
### 公开导出
|
|
||||||
|
|
||||||
template 导出两个对象供消费方使用:
|
|
||||||
|
|
||||||
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow` 的 `def` 参数
|
|
||||||
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// bundle 侧用法
|
|
||||||
export const descriptor = buildDocumentDescriptor();
|
|
||||||
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
|
|
||||||
```
|
|
||||||
|
|
||||||
### 包文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/workflow-template-document/
|
|
||||||
src/
|
|
||||||
types.ts # DocumentStartInput
|
|
||||||
roles/
|
|
||||||
writer.ts # writerMetaSchema, WriterMeta, writerRole
|
|
||||||
differ.ts # differMetaSchema, DifferMeta, differRole
|
|
||||||
index.ts
|
|
||||||
roles.ts # DocumentMeta, documentRoles
|
|
||||||
moderator.ts # writerIsEditMode condition + documentTable
|
|
||||||
definition.ts # documentWorkflowDefinition
|
|
||||||
descriptor.ts # buildDocumentDescriptor()
|
|
||||||
index.ts
|
|
||||||
__tests__/
|
|
||||||
moderator.test.ts
|
|
||||||
package.json
|
|
||||||
tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 依赖
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"@uncaged/workflow-register": "workspace:^",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、`workflow-agent-office`
|
|
||||||
|
|
||||||
### office-agent CLI 接口
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 生成模式:在 CWD 生成 output.docx
|
|
||||||
office-agent create "<prompt>" -o output.docx
|
|
||||||
|
|
||||||
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
|
|
||||||
office-agent edit modified.docx "<instruction>"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
|
|
||||||
- 输出文件落到调用方设定的 CWD
|
|
||||||
- 退出码 0 = 成功,非零 = 失败
|
|
||||||
|
|
||||||
### 文件命名约定
|
|
||||||
|
|
||||||
| 模式 | 文件 | 路径 |
|
|
||||||
|---|---|---|
|
|
||||||
| generate | 输出 | `<outputDir>/output.docx` |
|
|
||||||
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
|
|
||||||
| edit | 修改后产物 | `<outputDir>/modified.docx` |
|
|
||||||
|
|
||||||
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx`,`original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
|
|
||||||
|
|
||||||
### 执行流程
|
|
||||||
|
|
||||||
**生成模式(`inputDocx = null`):**
|
|
||||||
1. `mkdir -p <outputDir>`(`<config.outputDir>/<ctx.threadId>`)
|
|
||||||
2. `const command = config.command ?? "office-agent"`
|
|
||||||
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
|
|
||||||
4. 验证 `outputDir/output.docx` 存在
|
|
||||||
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
|
|
||||||
|
|
||||||
**编辑模式(`inputDocx ≠ null`):**
|
|
||||||
1. `mkdir -p <outputDir>`
|
|
||||||
2. `copyFile(inputDocx, <outputDir>/original.docx)`
|
|
||||||
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
|
|
||||||
4. `const command = config.command ?? "office-agent"`
|
|
||||||
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
|
|
||||||
6. 验证 `outputDir/modified.docx` 存在
|
|
||||||
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
|
|
||||||
|
|
||||||
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
|
||||||
|
|
||||||
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
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);
|
|
||||||
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
|
|
||||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
|
||||||
return { meta, childThread: null };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type OfficeAgentConfig = {
|
|
||||||
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
|
|
||||||
command: string | null; // null → runner 内 resolve 为 "office-agent"
|
|
||||||
timeout: number | null; // null → 不设超时;单位 ms
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 错误处理
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (!result.ok) {
|
|
||||||
const e = result.error;
|
|
||||||
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");
|
|
||||||
// "spawn_failed"
|
|
||||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
if (!existsSync(expectedPath))
|
|
||||||
throw new Error(`office-agent: output file not found: ${expectedPath}`);
|
|
||||||
```
|
|
||||||
|
|
||||||
### packageDescriptor
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/package-descriptor.ts
|
|
||||||
export const packageDescriptor: PackageDescriptor = {
|
|
||||||
name: "@uncaged/workflow-agent-office",
|
|
||||||
version: "0.1.0",
|
|
||||||
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
|
|
||||||
configSchema: {
|
|
||||||
type: "object",
|
|
||||||
required: ["outputDir"],
|
|
||||||
properties: {
|
|
||||||
outputDir: { type: "string", description: "Root directory for workflow outputs." },
|
|
||||||
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
|
|
||||||
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 包文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/workflow-agent-office/
|
|
||||||
src/
|
|
||||||
types.ts # OfficeAgentConfig, OfficeAgentOpt
|
|
||||||
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
|
|
||||||
agent.ts # createOfficeAgent(): AdapterFn
|
|
||||||
package-descriptor.ts # packageDescriptor
|
|
||||||
index.ts
|
|
||||||
__tests__/
|
|
||||||
runner.test.ts
|
|
||||||
agent.test.ts
|
|
||||||
package.json
|
|
||||||
tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 依赖
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
|
||||||
"@uncaged/workflow-util": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、`workflow-agent-docx-diff`
|
|
||||||
|
|
||||||
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
|
|
||||||
|
|
||||||
### docx-diff 退出码约定
|
|
||||||
|
|
||||||
| 退出码 | 含义 | runner 处理 |
|
|
||||||
|---|---|---|
|
|
||||||
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
|
|
||||||
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
|
|
||||||
| 2+ | 错误 | throw |
|
|
||||||
|
|
||||||
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
|
|
||||||
|
|
||||||
### 执行流程
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
|
|
||||||
2. 验证 mode === "edit"(否则 throw)
|
|
||||||
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
|
|
||||||
4. const command = config.command ?? "docx-diff"
|
|
||||||
5. spawnCli(command,
|
|
||||||
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
|
|
||||||
{ cwd: null, timeoutMs: null })
|
|
||||||
exit 0 或 1 → 验证 diffDocx 存在
|
|
||||||
exit 2+ → throw
|
|
||||||
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
|
|
||||||
```
|
|
||||||
|
|
||||||
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
|
|
||||||
return <T>(_prompt: string, schema: z.ZodType<T>) =>
|
|
||||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
|
||||||
const writerStep = ctx.steps.find(s => s.role === "writer");
|
|
||||||
if (!writerStep) 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");
|
|
||||||
const raw = await runDocxDiff(config, writerMeta);
|
|
||||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
|
||||||
return { meta, childThread: null };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type DocxDiffAgentConfig = {
|
|
||||||
command: string | null; // null → runner 内 resolve 为 "docx-diff"
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### packageDescriptor
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const packageDescriptor: PackageDescriptor = {
|
|
||||||
name: "@uncaged/workflow-agent-docx-diff",
|
|
||||||
version: "0.1.0",
|
|
||||||
capabilities: ["docx-diff-cli", "docx-diff-report"],
|
|
||||||
configSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 包文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/workflow-agent-docx-diff/
|
|
||||||
src/
|
|
||||||
types.ts # DocxDiffAgentConfig
|
|
||||||
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
|
|
||||||
agent.ts # createDocxDiffAgent(): AdapterFn
|
|
||||||
package-descriptor.ts # packageDescriptor
|
|
||||||
index.ts
|
|
||||||
__tests__/
|
|
||||||
runner.test.ts
|
|
||||||
agent.test.ts
|
|
||||||
package.json
|
|
||||||
tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 依赖
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^",
|
|
||||||
"@uncaged/workflow-template-document": "workspace:^"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、外部 bundle(外部 workspace 消费)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
|
|
||||||
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
|
|
||||||
import {
|
|
||||||
buildDocumentDescriptor,
|
|
||||||
documentWorkflowDefinition,
|
|
||||||
} from "@uncaged/workflow-template-document";
|
|
||||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
|
||||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
|
|
||||||
|
|
||||||
export const descriptor = buildDocumentDescriptor();
|
|
||||||
export const run = createWorkflow(documentWorkflowDefinition, {
|
|
||||||
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
|
|
||||||
overrides: { differ: createDocxDiffAgent() },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 不在范围内
|
|
||||||
|
|
||||||
- 重试逻辑(失败直接 throw)
|
|
||||||
- office-agent server 的启停管理(假设 server 已在运行)
|
|
||||||
- docx-diff HTML/terminal 格式输出(仅 docx)
|
|
||||||
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Sync README
|
|
||||||
|
|
||||||
When updating README.md files in this monorepo, follow these conventions.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Root `README.md` — project overview and navigation hub
|
|
||||||
- Per-package `packages/*/README.md` — each package self-contained
|
|
||||||
|
|
||||||
## Root README Structure
|
|
||||||
|
|
||||||
The root README should have these sections in order:
|
|
||||||
|
|
||||||
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
|
|
||||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
|
||||||
3. **Architecture** — dependency layer diagram (text-based)
|
|
||||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
|
|
||||||
5. **Quick Start** — install, build, register workflow, start thread, run step
|
|
||||||
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
|
|
||||||
7. **Development** — bun install / build / check / test
|
|
||||||
|
|
||||||
## Per-Package README Structure
|
|
||||||
|
|
||||||
Each package README should have:
|
|
||||||
|
|
||||||
1. **Title** — package name
|
|
||||||
2. **One-line description** — matching package.json
|
|
||||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
|
||||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
|
|
||||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
|
||||||
6. **CLI Usage** (cli/agent packages) — command reference with examples
|
|
||||||
7. **Internal Structure** — brief src/ file organization
|
|
||||||
8. **Configuration** (if applicable)
|
|
||||||
|
|
||||||
## Execution Steps
|
|
||||||
|
|
||||||
### Step 1: Gather current state
|
|
||||||
For each package read:
|
|
||||||
- package.json (name, version, description, dependencies, bin)
|
|
||||||
- src/index.ts (public API exports)
|
|
||||||
- Existing README.md (preserve hand-written content worth keeping)
|
|
||||||
|
|
||||||
### Step 2: Update root README
|
|
||||||
- Ensure ALL packages in packages/ directory are listed in the table
|
|
||||||
- Update CLI command reference from uwf --help output
|
|
||||||
- Keep Quick Start examples valid
|
|
||||||
|
|
||||||
### Step 3: Write/update each package README
|
|
||||||
- Follow the per-package structure
|
|
||||||
- API section MUST match actual src/index.ts exports — never invent
|
|
||||||
- For agent packages: document CLI binary name, how it is invoked
|
|
||||||
- For lib packages: document exported types and functions
|
|
||||||
- Internal structure: list actual files in src/
|
|
||||||
|
|
||||||
### Step 4: Verify
|
|
||||||
- All relative links work
|
|
||||||
- Package names match package.json
|
|
||||||
- No references to removed/renamed packages
|
|
||||||
- bun run build still passes
|
|
||||||
|
|
||||||
## Guidelines
|
|
||||||
|
|
||||||
- Only document what src/index.ts actually exports
|
|
||||||
- Root README summarizes, package READMEs go into detail
|
|
||||||
- Verify CLI examples against actual commands
|
|
||||||
- Preserve existing good prose when updating
|
|
||||||
- English for all README content
|
|
||||||
@@ -1,517 +0,0 @@
|
|||||||
# `uwf` — Stateless Workflow CLI
|
|
||||||
|
|
||||||
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. CLI Design
|
|
||||||
|
|
||||||
### 1.1 命令总览
|
|
||||||
|
|
||||||
```
|
|
||||||
# thread 组
|
|
||||||
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
|
|
||||||
uwf thread step <thread-id> [--agent] # 单步执行
|
|
||||||
uwf thread show <thread-id> # thread-id → head 查询
|
|
||||||
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
|
|
||||||
uwf thread kill <thread-id> # 终结 thread,归档
|
|
||||||
|
|
||||||
# workflow 组
|
|
||||||
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
|
|
||||||
uwf workflow show <workflow-id> # 查看 workflow 定义
|
|
||||||
uwf workflow list # 列出已注册 workflows
|
|
||||||
```
|
|
||||||
|
|
||||||
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
|
|
||||||
|
|
||||||
### 1.2 `uwf thread start`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
|
|
||||||
```
|
|
||||||
|
|
||||||
- `<workflow>` — workflow 名或 CAS hash
|
|
||||||
- `-p` — 用户 prompt(必填)
|
|
||||||
|
|
||||||
**输出(JSON to stdout):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
|
|
||||||
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**做的事:**
|
|
||||||
1. 解析 workflow(名字查 registry → CAS hash)
|
|
||||||
2. 生成 thread ULID
|
|
||||||
3. 写 StartNode 到 CAS
|
|
||||||
4. 在 threads.yaml 中记录链头 → StartNode hash
|
|
||||||
5. 输出 JSON
|
|
||||||
|
|
||||||
### 1.3 `uwf thread step`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
|
|
||||||
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
|
|
||||||
```
|
|
||||||
|
|
||||||
**输出(JSON to stdout):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"workflow": "4KNM2PXR3B1QW",
|
|
||||||
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
|
|
||||||
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
|
|
||||||
"done": false // true = moderator 返回 END,thread 已归档
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
|
|
||||||
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
|
|
||||||
|
|
||||||
详细信息通过 `uwf thread show <thread-id>` 或 `json-cas get <head>` 查看。
|
|
||||||
|
|
||||||
**做的事:**
|
|
||||||
1. 读链头 → 当前 StepNode(或 StartNode)
|
|
||||||
2. 收集 thread 历史(遍历链)
|
|
||||||
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
|
|
||||||
4. 若 END → 归档 thread,输出最后链头,退出
|
|
||||||
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
|
||||||
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
|
||||||
7. 更新链头指针
|
|
||||||
8. 再次调 moderator(基于新 StepNode)判断 done
|
|
||||||
9. 输出 JSON
|
|
||||||
|
|
||||||
### 1.4 `uwf thread show`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
|
|
||||||
```
|
|
||||||
|
|
||||||
**输出(JSON to stdout):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"workflow": "4KNM2PXR3B1QW",
|
|
||||||
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
|
|
||||||
"head": "8FWKR3TN5V1QA",
|
|
||||||
"done": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
纯 thread-id → head 查询。详细内容用 `json-cas get <head>` 或 `json-cas walk <head>` 查看。
|
|
||||||
|
|
||||||
### 1.5 Agent CLI 协议
|
|
||||||
|
|
||||||
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uwf-hermes <thread-id> <role>
|
|
||||||
```
|
|
||||||
|
|
||||||
**约定:**
|
|
||||||
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
|
||||||
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
|
|
||||||
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
|
|
||||||
- agent 执行实际逻辑,agent-kit 负责 extract
|
|
||||||
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
|
||||||
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
|
||||||
- 所有配置从环境变量读(LLM model、API key、extractor config)
|
|
||||||
- exit 0 = 成功,非 0 = 失败
|
|
||||||
|
|
||||||
**stdout 输出:**
|
|
||||||
|
|
||||||
```
|
|
||||||
8FWKR3TN5V1QA
|
|
||||||
```
|
|
||||||
|
|
||||||
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. CAS 结构定义
|
|
||||||
|
|
||||||
### 2.1 类型层级
|
|
||||||
|
|
||||||
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
|
|
||||||
|
|
||||||
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
|
|
||||||
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
|
|
||||||
|
|
||||||
### 2.2 数据节点
|
|
||||||
|
|
||||||
#### `Workflow`
|
|
||||||
|
|
||||||
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: <workflow-schema-hash>
|
|
||||||
payload:
|
|
||||||
name: "solve-issue"
|
|
||||||
description: "End-to-end issue resolution"
|
|
||||||
roles:
|
|
||||||
planner:
|
|
||||||
description: "Creates implementation plan"
|
|
||||||
goal: "You are a planning agent..."
|
|
||||||
capabilities: [planning, issue-analysis]
|
|
||||||
procedure: "Analyze the issue and create a plan."
|
|
||||||
output: "Output the plan summary."
|
|
||||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
|
||||||
developer:
|
|
||||||
description: "Implements code changes"
|
|
||||||
goal: "You are a developer agent..."
|
|
||||||
capabilities: [file-edit, shell]
|
|
||||||
procedure: "Implement the plan."
|
|
||||||
output: "List all files changed."
|
|
||||||
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
|
||||||
reviewer:
|
|
||||||
description: "Reviews code changes"
|
|
||||||
goal: "You are a code reviewer..."
|
|
||||||
capabilities: [code-review]
|
|
||||||
procedure: "Review the implementation."
|
|
||||||
output: "Approve or reject with comments."
|
|
||||||
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
|
||||||
conditions:
|
|
||||||
needsClarification:
|
|
||||||
description: "Planner requests clarification from user"
|
|
||||||
expression: "$exists(steps[-1].output.needsClarification)"
|
|
||||||
notApproved:
|
|
||||||
description: "Reviewer rejected the implementation"
|
|
||||||
expression: "steps[-1].output.approved = false"
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
- role: "planner"
|
|
||||||
condition: null # 无条件(fallback)
|
|
||||||
planner:
|
|
||||||
- role: "developer"
|
|
||||||
condition: "needsClarification"
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
developer:
|
|
||||||
- role: "reviewer"
|
|
||||||
condition: null
|
|
||||||
reviewer:
|
|
||||||
- role: "developer"
|
|
||||||
condition: "notApproved"
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
```
|
|
||||||
|
|
||||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
|
||||||
- `graph` — `Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
|
|
||||||
- Status 来自上一个 role 输出的 `status` 字段,`$START` 用 `_` 作为初始 status
|
|
||||||
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
|
|
||||||
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
|
||||||
|
|
||||||
Moderator 的求值逻辑:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
evaluate(graph, lastRole, lastOutput) → { role, prompt }
|
|
||||||
// 1. status = lastRole === "$START" ? "_" : lastOutput.status
|
|
||||||
// 2. target = graph[lastRole][status]
|
|
||||||
// 3. prompt = mustache.render(target.prompt, lastOutput)
|
|
||||||
```
|
|
||||||
|
|
||||||
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target。
|
|
||||||
|
|
||||||
#### `StartNode`(Thread 起点)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: <start-node-schema-hash>
|
|
||||||
payload:
|
|
||||||
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
|
||||||
prompt: "Fix the login bug..."
|
|
||||||
```
|
|
||||||
|
|
||||||
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
|
|
||||||
- 没有 agent binding — 运行时从 config.yaml 解析
|
|
||||||
|
|
||||||
#### `StepNode`(Thread 每一步)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: <step-node-schema-hash>
|
|
||||||
payload:
|
|
||||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
|
||||||
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
|
||||||
role: "developer"
|
|
||||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
|
|
||||||
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
|
||||||
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
|
||||||
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
|
||||||
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
|
|
||||||
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
|
||||||
- `agent` — 纯字符串,不是 CAS 节点
|
|
||||||
|
|
||||||
### 2.3 链式结构
|
|
||||||
|
|
||||||
```
|
|
||||||
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
StepNode (step 3)
|
|
||||||
├── start ──→ StartNode
|
|
||||||
│ ├── workflow → CAS(Workflow)
|
|
||||||
│ └── prompt: "Fix..."
|
|
||||||
├── prev ──→ StepNode (step 2)
|
|
||||||
│ ├── start ──→ (same StartNode)
|
|
||||||
│ ├── prev ──→ StepNode (step 1)
|
|
||||||
│ │ ├── start ──→ (same StartNode)
|
|
||||||
│ │ ├── prev: null
|
|
||||||
│ │ ├── role: "planner"
|
|
||||||
│ │ └── ...
|
|
||||||
│ ├── role: "developer"
|
|
||||||
│ └── ...
|
|
||||||
├── role: "reviewer"
|
|
||||||
├── output → CAS({ approved: true })
|
|
||||||
├── detail → CAS(raw output | sub-workflow terminal node)
|
|
||||||
└── agent: "uwf-hermes"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 可变状态
|
|
||||||
|
|
||||||
系统两个顶层 YAML 文件和一个 env 文件:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ~/.uncaged/workflow/config.yaml — 全局配置
|
|
||||||
providers:
|
|
||||||
openai:
|
|
||||||
baseUrl: "https://api.openai.com/v1"
|
|
||||||
apiKey: "sk-..."
|
|
||||||
anthropic:
|
|
||||||
baseUrl: "https://api.anthropic.com/v1"
|
|
||||||
apiKey: "sk-ant-..."
|
|
||||||
openrouter:
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1"
|
|
||||||
apiKey: "sk-or-..."
|
|
||||||
|
|
||||||
models:
|
|
||||||
sonnet:
|
|
||||||
provider: "openrouter"
|
|
||||||
name: "anthropic/claude-sonnet-4"
|
|
||||||
gpt4o-mini:
|
|
||||||
provider: "openai"
|
|
||||||
name: "gpt-4o-mini"
|
|
||||||
|
|
||||||
agents:
|
|
||||||
hermes:
|
|
||||||
command: "uwf-hermes"
|
|
||||||
args: []
|
|
||||||
cursor:
|
|
||||||
command: "uwf-cursor"
|
|
||||||
args: []
|
|
||||||
|
|
||||||
defaultAgent: "hermes"
|
|
||||||
agentOverrides:
|
|
||||||
solve-issue:
|
|
||||||
developer: "cursor"
|
|
||||||
|
|
||||||
defaultModel: "sonnet"
|
|
||||||
modelOverrides:
|
|
||||||
extract: "gpt4o-mini"
|
|
||||||
```
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ~/.uncaged/workflow/threads.yaml — active thread 链头指针
|
|
||||||
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
|
|
||||||
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
|
|
||||||
```
|
|
||||||
|
|
||||||
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ~/.uncaged/workflow/.env — 敏感信息(API keys)
|
|
||||||
OPENAI_API_KEY=sk-...
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
OPENROUTER_API_KEY=sk-or-...
|
|
||||||
```
|
|
||||||
|
|
||||||
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
|
|
||||||
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
|
|
||||||
- `threads.yaml` — 运行时状态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 包结构
|
|
||||||
|
|
||||||
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`。
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/
|
|
||||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令,含 src/moderator/)
|
|
||||||
├── workflow-util-agent/ # @uncaged/workflow-util-agent — Agent CLI 框架(含 extractor)
|
|
||||||
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
|
||||||
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
|
||||||
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
|
|
||||||
```
|
|
||||||
|
|
||||||
**外部依赖:**
|
|
||||||
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
|
|
||||||
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
|
|
||||||
|
|
||||||
**现有包全部保留不动**,新旧并存,逐步迁移。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 关键数据类型
|
|
||||||
|
|
||||||
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
|
||||||
|
|
||||||
### 4.1 公共类型
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/** CAS hash — XXH64, 13-char Crockford Base32 */
|
|
||||||
type CasRef = string;
|
|
||||||
|
|
||||||
/** Thread ID — ULID, 26-char Crockford Base32 */
|
|
||||||
type ThreadId = string;
|
|
||||||
|
|
||||||
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
|
|
||||||
type StepRecord = {
|
|
||||||
role: string;
|
|
||||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
|
||||||
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
|
||||||
agent: string; // 实际使用的 agent 命令(纯字符串)
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Workflow 定义
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type RoleDefinition = {
|
|
||||||
description: string;
|
|
||||||
goal: string;
|
|
||||||
capabilities: string[];
|
|
||||||
procedure: string;
|
|
||||||
output: string;
|
|
||||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
|
||||||
};
|
|
||||||
|
|
||||||
type Target = {
|
|
||||||
role: string; // 目标 role 名 或 "$END"
|
|
||||||
prompt: string; // Mustache 模板,渲染时注入 lastOutput
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkflowPayload = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
roles: Record<string, RoleDefinition>;
|
|
||||||
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 Thread 节点
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type StartNodePayload = {
|
|
||||||
workflow: CasRef; // cas_ref → Workflow
|
|
||||||
prompt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StepNodePayload = StepRecord & {
|
|
||||||
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
|
|
||||||
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Moderator 求值
|
|
||||||
|
|
||||||
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// graph[lastRole][lastOutput.status] → Target { role, prompt }
|
|
||||||
// $START 角色使用 "_" 作为初始 status
|
|
||||||
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 CLI 输出
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/** uwf thread start */
|
|
||||||
type StartOutput = {
|
|
||||||
workflow: CasRef;
|
|
||||||
thread: ThreadId;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** uwf thread step / uwf thread show */
|
|
||||||
type StepOutput = {
|
|
||||||
workflow: CasRef;
|
|
||||||
thread: ThreadId;
|
|
||||||
head: CasRef;
|
|
||||||
done: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** uwf thread list */
|
|
||||||
type ThreadListItem = {
|
|
||||||
thread: ThreadId;
|
|
||||||
workflow: CasRef;
|
|
||||||
head: CasRef;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 配置
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/** Alias types for config references */
|
|
||||||
type AgentAlias = string;
|
|
||||||
type ModelAlias = string;
|
|
||||||
type ProviderAlias = string;
|
|
||||||
type WorkflowName = string;
|
|
||||||
type RoleName = string;
|
|
||||||
type Scenario = string; // e.g. "extract"
|
|
||||||
|
|
||||||
type ProviderConfig = {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string; // API key stored directly
|
|
||||||
};
|
|
||||||
|
|
||||||
type ModelConfig = {
|
|
||||||
provider: ProviderAlias;
|
|
||||||
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
|
|
||||||
};
|
|
||||||
|
|
||||||
type AgentConfig = {
|
|
||||||
command: string;
|
|
||||||
args: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** ~/.uncaged/workflow/config.yaml */
|
|
||||||
type WorkflowConfig = {
|
|
||||||
providers: Record<ProviderAlias, ProviderConfig>;
|
|
||||||
models: Record<ModelAlias, ModelConfig>;
|
|
||||||
agents: Record<AgentAlias, AgentConfig>;
|
|
||||||
defaultAgent: AgentAlias;
|
|
||||||
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
|
||||||
defaultModel: ModelAlias;
|
|
||||||
modelOverrides: Record<Scenario, ModelAlias> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** ~/.uncaged/workflow/threads.yaml */
|
|
||||||
type ThreadsIndex = Record<ThreadId, CasRef>;
|
|
||||||
// ^ thread-id ^ head StepNode/StartNode hash
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.7 类型关系图
|
|
||||||
|
|
||||||
```
|
|
||||||
WorkflowConfig (config.yaml)
|
|
||||||
ThreadsIndex (threads.yaml) ← 唯二可变状态
|
|
||||||
│
|
|
||||||
│ thread-id → head hash
|
|
||||||
▼
|
|
||||||
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
|
|
||||||
│ │ │
|
|
||||||
├── start → StartNodePayload│ │ (output 展开)
|
|
||||||
├── prev → StepNodePayload │ │
|
|
||||||
│ ├── role ├── role
|
|
||||||
│ ├── output (CasRef) ├── output (展开)
|
|
||||||
│ ├── detail (CasRef) ├── detail (CasRef)
|
|
||||||
│ └── agent (string) └── agent (string)
|
|
||||||
│
|
|
||||||
└── start.workflow → WorkflowPayload
|
|
||||||
├── roles: Record<name, RoleDefinition>
|
|
||||||
└── graph: Record<role, Record<status, Target>>
|
|
||||||
```
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
name: "analyze-topic"
|
|
||||||
description: "Single-role topic analysis using four-phase role description"
|
|
||||||
roles:
|
|
||||||
analyst:
|
|
||||||
description: "Analyzes a given topic and produces a structured summary"
|
|
||||||
goal: |
|
|
||||||
You are a research analyst with expertise in breaking down complex topics
|
|
||||||
into clear, structured summaries. You think critically and cite key points.
|
|
||||||
capabilities:
|
|
||||||
- research
|
|
||||||
- critical-thinking
|
|
||||||
- structured-writing
|
|
||||||
procedure: |
|
|
||||||
Analyze the topic by:
|
|
||||||
1. Identifying the main thesis or question
|
|
||||||
2. Listing 3-5 key points with brief explanations
|
|
||||||
3. Noting any counterarguments or caveats
|
|
||||||
Keep your analysis concise (under 500 words).
|
|
||||||
output: |
|
|
||||||
Provide your analysis as markdown under the frontmatter.
|
|
||||||
The frontmatter must include your structured findings.
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
$status:
|
|
||||||
enum: ["_"]
|
|
||||||
thesis:
|
|
||||||
type: string
|
|
||||||
keyPoints:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
caveats:
|
|
||||||
type: string
|
|
||||||
required: [$status, thesis, keyPoints]
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
|
||||||
analyst:
|
|
||||||
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
name: "debate"
|
|
||||||
description: "Structured debate between two sides. Tests cross-process session resume."
|
|
||||||
roles:
|
|
||||||
against:
|
|
||||||
description: "Argues against the proposition"
|
|
||||||
goal: |
|
|
||||||
You are a skilled debater arguing AGAINST the proposition.
|
|
||||||
Be logical, cite evidence, and directly address your opponent's points.
|
|
||||||
Keep each argument concise (under 200 words).
|
|
||||||
capabilities:
|
|
||||||
- argumentation
|
|
||||||
- critical-thinking
|
|
||||||
procedure: |
|
|
||||||
1. If this is the opening, present your strongest argument against the proposition.
|
|
||||||
2. If responding to the other side, directly counter their points with evidence and logic.
|
|
||||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
|
||||||
output: |
|
|
||||||
Provide your argument in the frontmatter.
|
|
||||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
|
||||||
Otherwise set status to "continue".
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
$status:
|
|
||||||
enum: ["continue", "conceded"]
|
|
||||||
argument:
|
|
||||||
type: string
|
|
||||||
required: [$status, argument]
|
|
||||||
for:
|
|
||||||
description: "Argues for the proposition"
|
|
||||||
goal: |
|
|
||||||
You are a skilled debater arguing FOR the proposition.
|
|
||||||
Be logical, cite evidence, and directly address your opponent's points.
|
|
||||||
Keep each argument concise (under 200 words).
|
|
||||||
capabilities:
|
|
||||||
- argumentation
|
|
||||||
- critical-thinking
|
|
||||||
procedure: |
|
|
||||||
1. Read the opposing side's latest argument carefully.
|
|
||||||
2. Counter their points with evidence and logic.
|
|
||||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
|
||||||
output: |
|
|
||||||
Provide your argument in the frontmatter.
|
|
||||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
|
||||||
Otherwise set status to "continue".
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
$status:
|
|
||||||
enum: ["continue", "conceded"]
|
|
||||||
argument:
|
|
||||||
type: string
|
|
||||||
required: [$status, argument]
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
|
||||||
against:
|
|
||||||
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
|
||||||
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
|
||||||
for:
|
|
||||||
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
|
||||||
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
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. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
|
||||||
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
|
|
||||||
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)
|
|
||||||
3. Set repoPath to the absolute path of the repository root
|
|
||||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "ready" }
|
|
||||||
plan: { type: string }
|
|
||||||
repoPath: { type: string }
|
|
||||||
required: [$status, plan, repoPath]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "insufficient_info" }
|
|
||||||
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: |
|
|
||||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
|
||||||
The repo path and other details are provided in your task prompt.
|
|
||||||
|
|
||||||
Before starting any work, set up an isolated worktree:
|
|
||||||
1. cd into the repo path provided in your task prompt
|
|
||||||
2. `git fetch origin` to get latest refs
|
|
||||||
3. First time (no existing branch):
|
|
||||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
|
||||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
|
||||||
4. If bounced back from reviewer or tester (branch already exists):
|
|
||||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
|
||||||
- `git fetch origin && git rebase origin/main`
|
|
||||||
5. ALL subsequent work must happen inside the worktree directory.
|
|
||||||
|
|
||||||
Then implement TDD:
|
|
||||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
|
||||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
|
||||||
8. Write tests first based on the spec
|
|
||||||
9. Implement the code to make tests pass
|
|
||||||
10. Ensure `bun run build` passes with no errors
|
|
||||||
11. Run `bun test` to verify all tests pass
|
|
||||||
|
|
||||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
|
||||||
or repeated attempts fail), set $status=failed with a reason.
|
|
||||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "done" }
|
|
||||||
branch: { type: string }
|
|
||||||
worktree: { type: string }
|
|
||||||
required: [$status, branch, worktree]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "failed" }
|
|
||||||
reason: { type: string }
|
|
||||||
required: [$status, reason]
|
|
||||||
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: |
|
|
||||||
The worktree path is provided in your task prompt. cd into it first.
|
|
||||||
|
|
||||||
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 project conventions if CLAUDE.md / .cursor/rules exist):
|
|
||||||
- Naming conventions, module boundaries, code style
|
|
||||||
- No `console.log` in production code
|
|
||||||
- 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. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "approved" }
|
|
||||||
branch: { type: string }
|
|
||||||
worktree: { type: string }
|
|
||||||
required: [$status, branch, worktree]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "rejected" }
|
|
||||||
comments: { type: string }
|
|
||||||
worktree: { type: string }
|
|
||||||
required: [$status, comments, worktree]
|
|
||||||
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: |
|
|
||||||
The worktree path is provided in your task prompt. cd into it first.
|
|
||||||
|
|
||||||
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 planner step in the thread history)
|
|
||||||
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. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "passed" }
|
|
||||||
branch: { type: string }
|
|
||||||
worktree: { type: string }
|
|
||||||
required: [$status, branch, worktree]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fix_code" }
|
|
||||||
report: { type: string }
|
|
||||||
required: [$status, report]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "fix_spec" }
|
|
||||||
report: { type: string }
|
|
||||||
required: [$status, report]
|
|
||||||
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: |
|
|
||||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
|
||||||
cd into the worktree first.
|
|
||||||
|
|
||||||
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 --repo <owner/repo> --title "..." --description "..."`
|
|
||||||
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
|
||||||
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
|
||||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
|
||||||
5. After PR creation, clean up the worktree:
|
|
||||||
- cd to the repo root (parent of .worktrees)
|
|
||||||
- `git worktree remove <worktree-path>`
|
|
||||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
|
||||||
frontmatter:
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
$status: { const: "committed" }
|
|
||||||
prUrl: { type: string }
|
|
||||||
required: [$status, prUrl]
|
|
||||||
- properties:
|
|
||||||
$status: { const: "hook_failed" }
|
|
||||||
error: { type: string }
|
|
||||||
required: [$status, error]
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
|
||||||
planner:
|
|
||||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
|
||||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
|
||||||
developer:
|
|
||||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
|
||||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
|
||||||
reviewer:
|
|
||||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
|
||||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
|
||||||
tester:
|
|
||||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
|
||||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
|
||||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
|
||||||
committer:
|
|
||||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
|
||||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# @uncaged/cli-workflow
|
|
||||||
|
|
||||||
## 0.5.0-alpha.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- Updated dependencies [f74b482]
|
|
||||||
- Updated dependencies [f74b482]
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-execute@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-gateway@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
|
||||||
|
|
||||||
## 0.5.0-alpha.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-execute@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-gateway@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
|
||||||
|
|
||||||
## 0.5.0-alpha.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-execute@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-gateway@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
|
||||||
|
|
||||||
## 0.5.0-alpha.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-execute@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-gateway@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
|
||||||
|
|
||||||
## 0.5.0-alpha.0
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-execute@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-gateway@0.5.0-alpha.0
|
|
||||||
|
|
||||||
## 0.4.5
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.5
|
|
||||||
- @uncaged/workflow-cas@0.4.5
|
|
||||||
- @uncaged/workflow-execute@0.4.5
|
|
||||||
- @uncaged/workflow-gateway@0.4.5
|
|
||||||
- @uncaged/workflow-register@0.4.5
|
|
||||||
- @uncaged/workflow-runtime@0.4.5
|
|
||||||
- @uncaged/workflow-util@0.4.5
|
|
||||||
|
|
||||||
## 0.4.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.4
|
|
||||||
- @uncaged/workflow-cas@0.4.4
|
|
||||||
- @uncaged/workflow-execute@0.4.4
|
|
||||||
- @uncaged/workflow-gateway@0.4.4
|
|
||||||
- @uncaged/workflow-register@0.4.4
|
|
||||||
- @uncaged/workflow-runtime@0.4.4
|
|
||||||
- @uncaged/workflow-util@0.4.4
|
|
||||||
|
|
||||||
## 0.4.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-cas@0.4.3
|
|
||||||
- @uncaged/workflow-execute@0.4.3
|
|
||||||
- @uncaged/workflow-gateway@0.4.3
|
|
||||||
- @uncaged/workflow-protocol@0.4.3
|
|
||||||
- @uncaged/workflow-register@0.4.3
|
|
||||||
- @uncaged/workflow-runtime@0.4.3
|
|
||||||
- @uncaged/workflow-util@0.4.3
|
|
||||||
|
|
||||||
## 0.4.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-cas@0.4.2
|
|
||||||
- @uncaged/workflow-execute@0.4.2
|
|
||||||
- @uncaged/workflow-gateway@0.4.2
|
|
||||||
- @uncaged/workflow-protocol@0.4.2
|
|
||||||
- @uncaged/workflow-register@0.4.2
|
|
||||||
- @uncaged/workflow-runtime@0.4.2
|
|
||||||
- @uncaged/workflow-util@0.4.2
|
|
||||||
|
|
||||||
## 0.4.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- Fix package exports for published packages and adopt changesets for version management.
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-cas@0.4.0
|
|
||||||
- @uncaged/workflow-execute@0.4.0
|
|
||||||
- @uncaged/workflow-gateway@0.4.0
|
|
||||||
- @uncaged/workflow-protocol@0.4.0
|
|
||||||
- @uncaged/workflow-register@0.4.0
|
|
||||||
- @uncaged/workflow-runtime@0.4.0
|
|
||||||
- @uncaged/workflow-util@0.4.0
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# @uncaged/cli-workflow
|
|
||||||
|
|
||||||
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
|
|
||||||
|
|
||||||
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @uncaged/cli-workflow
|
|
||||||
```
|
|
||||||
|
|
||||||
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uncaged-workflow workflow list
|
|
||||||
uncaged-workflow run <name> --prompt "Your task"
|
|
||||||
uncaged-workflow thread show <id>
|
|
||||||
uncaged-workflow skill
|
|
||||||
```
|
|
||||||
|
|
||||||
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
|
|
||||||
|
|
||||||
```
|
|
||||||
uncaged-workflow — workflow engine CLI
|
|
||||||
|
|
||||||
Workflow registry:
|
|
||||||
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
|
|
||||||
workflow list List all registered workflows
|
|
||||||
workflow show <name> Show details of a registered workflow
|
|
||||||
workflow rm <name> Remove a workflow from the registry
|
|
||||||
workflow history <name> Show version history of a workflow
|
|
||||||
workflow rollback <name> [hash] Rollback a workflow to a previous version
|
|
||||||
|
|
||||||
Thread execution:
|
|
||||||
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
|
|
||||||
thread list [name] List threads, optionally filtered by workflow name
|
|
||||||
thread show <id> Show thread details and state
|
|
||||||
thread rm <id> Remove a thread
|
|
||||||
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
|
|
||||||
thread ps List running threads
|
|
||||||
thread kill <thread-id> Kill a running thread
|
|
||||||
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
|
|
||||||
thread pause <thread-id> Pause a running thread
|
|
||||||
thread resume <thread-id> Resume a paused thread
|
|
||||||
|
|
||||||
Content-addressable storage:
|
|
||||||
cas get <hash> Retrieve content by hash from CAS
|
|
||||||
cas put <content> Store content in CAS, prints hash
|
|
||||||
cas list List all hashes in CAS
|
|
||||||
cas rm <hash> Remove a CAS entry by hash
|
|
||||||
cas gc Garbage-collect unreferenced CAS entries
|
|
||||||
|
|
||||||
Development:
|
|
||||||
init workspace <name> Initialize a new workflow workspace
|
|
||||||
init template <name> Initialize a new workflow template
|
|
||||||
|
|
||||||
Shortcuts:
|
|
||||||
run <name> [...] → thread run
|
|
||||||
live <id> [...] → thread live
|
|
||||||
|
|
||||||
Reference:
|
|
||||||
skill [topic] Agent-consumable docs (cli, develop, author)
|
|
||||||
|
|
||||||
Use <command> --help for subcommand details.
|
|
||||||
|
|
||||||
Environment variables:
|
|
||||||
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
|
|
||||||
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
|
|
||||||
```
|
|
||||||
|
|
||||||
## API overview
|
|
||||||
|
|
||||||
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts` → `runCli` in `src/cli-dispatch.js`.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/cli-workflow",
|
|
||||||
"version": "0.5.0-alpha.4",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"bin": {
|
|
||||||
"uncaged-workflow": "src/cli.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow-gateway": "workspace:^",
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
|
||||||
"@uncaged/workflow-util": "workspace:^",
|
|
||||||
"@uncaged/workflow-cas": "workspace:^",
|
|
||||||
"@uncaged/workflow-execute": "workspace:^",
|
|
||||||
"@uncaged/workflow-register": "workspace:^",
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"hono": "^4.12.18",
|
|
||||||
"yaml": "^2.8.4"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { runCli } from "./cli-dispatch.js";
|
|
||||||
import { resolveWorkflowStorageRoot } from "./storage-env.js";
|
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
|
||||||
const storageRoot = resolveWorkflowStorageRoot();
|
|
||||||
const code = await runCli(storageRoot, argv);
|
|
||||||
process.exit(code);
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { hostname as osHostname } from "node:os";
|
|
||||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
|
||||||
|
|
||||||
import { printCliLine } from "../../cli-output.js";
|
|
||||||
import { createApp } from "./app.js";
|
|
||||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
|
||||||
import type { ConnectOptions } from "./types.js";
|
|
||||||
import { startGatewayWsClient } from "./ws-client.js";
|
|
||||||
|
|
||||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
||||||
|
|
||||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
|
||||||
const next = argv[i + 1];
|
|
||||||
if (next === undefined) {
|
|
||||||
return { ok: false, error: `${flag} requires a value` };
|
|
||||||
}
|
|
||||||
return ok(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
|
|
||||||
let name = osHostname().split(".")[0].toLowerCase();
|
|
||||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
|
||||||
const gatewaySecret = process.env.WORKFLOW_DASHBOARD_SECRET ?? "";
|
|
||||||
const stringFlags: Record<string, (v: string) => void> = {
|
|
||||||
"--name": (v) => {
|
|
||||||
name = v;
|
|
||||||
},
|
|
||||||
"--gateway": (v) => {
|
|
||||||
gatewayUrl = v;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
|
||||||
const arg = argv[i];
|
|
||||||
if (arg in stringFlags) {
|
|
||||||
const r = requireNextArg(argv, i, arg);
|
|
||||||
if (!r.ok) return r;
|
|
||||||
stringFlags[arg](r.value);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok({ name, gatewayUrl, gatewaySecret });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dispatchConnect(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const parsed = parseConnectArgv(argv);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
printCliLine(`error: ${parsed.error}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = parsed.value;
|
|
||||||
|
|
||||||
if (options.gatewaySecret === "") {
|
|
||||||
printCliLine("error: WORKFLOW_DASHBOARD_SECRET is required");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientToken = randomUUID();
|
|
||||||
const app = createApp(storageRoot, clientToken);
|
|
||||||
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
|
||||||
const stopWsClient = startGatewayWsClient({
|
|
||||||
gatewayUrl: options.gatewayUrl,
|
|
||||||
name: options.name,
|
|
||||||
secret: options.gatewaySecret,
|
|
||||||
appFetch: app.fetch,
|
|
||||||
log,
|
|
||||||
});
|
|
||||||
|
|
||||||
printCliLine("connected to gateway via WebSocket");
|
|
||||||
|
|
||||||
// Register with gateway for discovery
|
|
||||||
const registered = await registerWithGateway(
|
|
||||||
options.gatewayUrl,
|
|
||||||
options.name,
|
|
||||||
`ws://${options.name}`,
|
|
||||||
options.gatewaySecret,
|
|
||||||
clientToken,
|
|
||||||
);
|
|
||||||
if (registered) {
|
|
||||||
printCliLine(`registered with gateway as "${options.name}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const heartbeatTimer = startHeartbeat(
|
|
||||||
options.gatewayUrl,
|
|
||||||
options.name,
|
|
||||||
`ws://${options.name}`,
|
|
||||||
options.gatewaySecret,
|
|
||||||
clientToken,
|
|
||||||
HEARTBEAT_INTERVAL_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
const cleanup = async () => {
|
|
||||||
clearInterval(heartbeatTimer);
|
|
||||||
stopWsClient();
|
|
||||||
printCliLine("unregistering from gateway...");
|
|
||||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGINT", cleanup);
|
|
||||||
process.on("SIGTERM", cleanup);
|
|
||||||
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { dispatchConnect } from "./connect.js";
|
|
||||||
export type { ConnectOptions } from "./types.js";
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export type ConnectOptions = {
|
|
||||||
name: string;
|
|
||||||
gatewayUrl: string;
|
|
||||||
gatewaySecret: string;
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"exactOptionalPropertyTypes": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"composite": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"types": ["bun-types"]
|
|
||||||
},
|
|
||||||
"references": [
|
|
||||||
{ "path": "../workflow-runtime" },
|
|
||||||
{ "path": "../workflow-protocol" },
|
|
||||||
{ "path": "../workflow-util" },
|
|
||||||
{ "path": "../workflow-cas" },
|
|
||||||
{ "path": "../workflow-execute" },
|
|
||||||
{ "path": "../workflow-register" }
|
|
||||||
],
|
|
||||||
"include": ["src/**/*.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# @uncaged/workflow-agent-cursor
|
|
||||||
|
|
||||||
## 0.5.0-alpha.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- Updated dependencies [f74b482]
|
|
||||||
- Updated dependencies [f74b482]
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
|
||||||
|
|
||||||
## 0.5.0-alpha.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
|
||||||
|
|
||||||
## 0.5.0-alpha.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
|
||||||
|
|
||||||
## 0.5.0-alpha.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
|
||||||
|
|
||||||
## 0.5.0-alpha.0
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
|
||||||
|
|
||||||
## 0.4.5
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.5
|
|
||||||
- @uncaged/workflow-reactor@0.4.5
|
|
||||||
- @uncaged/workflow-runtime@0.4.5
|
|
||||||
- @uncaged/workflow-util@0.4.5
|
|
||||||
- @uncaged/workflow-util-agent@0.4.5
|
|
||||||
|
|
||||||
## 0.4.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.4
|
|
||||||
- @uncaged/workflow-reactor@0.4.4
|
|
||||||
- @uncaged/workflow-runtime@0.4.4
|
|
||||||
- @uncaged/workflow-util@0.4.4
|
|
||||||
- @uncaged/workflow-util-agent@0.4.4
|
|
||||||
|
|
||||||
## 0.4.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.3
|
|
||||||
- @uncaged/workflow-reactor@0.4.3
|
|
||||||
- @uncaged/workflow-runtime@0.4.3
|
|
||||||
- @uncaged/workflow-util-agent@0.4.3
|
|
||||||
- @uncaged/workflow-util@0.4.3
|
|
||||||
|
|
||||||
## 0.4.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.2
|
|
||||||
- @uncaged/workflow-reactor@0.4.2
|
|
||||||
- @uncaged/workflow-runtime@0.4.2
|
|
||||||
- @uncaged/workflow-util-agent@0.4.2
|
|
||||||
- @uncaged/workflow-util@0.4.2
|
|
||||||
|
|
||||||
## 0.4.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- Fix package exports for published packages and adopt changesets for version management.
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.0
|
|
||||||
- @uncaged/workflow-reactor@0.4.0
|
|
||||||
- @uncaged/workflow-runtime@0.4.0
|
|
||||||
- @uncaged/workflow-util-agent@0.4.0
|
|
||||||
- @uncaged/workflow-util@0.4.0
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
|
||||||
|
|
||||||
const baseConfig = {
|
|
||||||
command: "/usr/local/bin/cursor-agent",
|
|
||||||
model: null as string | null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: null as string | null,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("validateCursorAgentConfig", () => {
|
|
||||||
test("accepts valid config", () => {
|
|
||||||
const r = validateCursorAgentConfig({
|
|
||||||
...baseConfig,
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects non-absolute command", () => {
|
|
||||||
const r = validateCursorAgentConfig({
|
|
||||||
...baseConfig,
|
|
||||||
command: "cursor-agent",
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.error).toContain("absolute path");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects negative timeout", () => {
|
|
||||||
const r = validateCursorAgentConfig({
|
|
||||||
...baseConfig,
|
|
||||||
timeout: -1,
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects non-absolute workspace when set", () => {
|
|
||||||
const r = validateCursorAgentConfig({
|
|
||||||
...baseConfig,
|
|
||||||
workspace: "relative/path",
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.error).toContain("workspace");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createCursorAgent", () => {
|
|
||||||
test("returns an AdapterFn", () => {
|
|
||||||
const agent = createCursorAgent({
|
|
||||||
...baseConfig,
|
|
||||||
});
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
|
||||||
const agent = createCursorAgent({
|
|
||||||
...baseConfig,
|
|
||||||
timeout: -1,
|
|
||||||
});
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-agent-cursor",
|
|
||||||
"version": "0.5.0-alpha.4",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow-cas": "workspace:^",
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"@uncaged/workflow-util": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"bun": "./src/index.ts",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
|
||||||
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
|
||||||
import type { LogFn } from "@uncaged/workflow-util";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
const workspaceSchema = z.object({
|
|
||||||
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildExtractionInput(ctx: ThreadContext): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push("## Task");
|
|
||||||
lines.push(ctx.start.content);
|
|
||||||
|
|
||||||
for (const step of ctx.steps) {
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`## Step: ${step.role}`);
|
|
||||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
lines.push(
|
|
||||||
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
|
|
||||||
);
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function extractWorkspacePath(
|
|
||||||
ctx: ThreadContext,
|
|
||||||
runtime: WorkflowRuntime,
|
|
||||||
logger: LogFn,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const input = buildExtractionInput(ctx);
|
|
||||||
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
|
|
||||||
|
|
||||||
const result = await runtime.extract(workspaceSchema, contentHash);
|
|
||||||
const workspace = result.meta.workspace.trim();
|
|
||||||
|
|
||||||
if (!workspace.startsWith("/")) {
|
|
||||||
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger("V3KM8QWP", `extracted workspace: ${workspace}`);
|
|
||||||
return workspace;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export type CursorAgentConfig = {
|
|
||||||
/** Absolute path to the cursor-agent CLI binary. */
|
|
||||||
command: string;
|
|
||||||
model: string | null;
|
|
||||||
timeout: number;
|
|
||||||
/**
|
|
||||||
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
|
|
||||||
* from the thread via runtime extraction.
|
|
||||||
*/
|
|
||||||
workspace: string | null;
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [
|
|
||||||
{ "path": "../workflow-cas" },
|
|
||||||
{ "path": "../workflow-runtime" },
|
|
||||||
{ "path": "../workflow-util-agent" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { createDocxDiffAgent } from "../src/agent.js";
|
|
||||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
|
||||||
|
|
||||||
describe("createDocxDiffAgent", () => {
|
|
||||||
test("returns an AdapterFn (function)", () => {
|
|
||||||
const agent = createDocxDiffAgent({ command: null });
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("AdapterFn returns a RoleFn (function)", () => {
|
|
||||||
const agent = createDocxDiffAgent({ command: null });
|
|
||||||
const roleFn = agent("", expect.anything() as never);
|
|
||||||
expect(typeof roleFn).toBe("function");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("packageDescriptor", () => {
|
|
||||||
test("has correct name", () => {
|
|
||||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test";
|
|
||||||
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";
|
|
||||||
|
|
||||||
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
|
|
||||||
|
|
||||||
function makeSpawn(result: MockSpawnResult) {
|
|
||||||
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tempDir(): string {
|
|
||||||
const dir = join(tmpdir(), `diff-test-${Date.now()}`);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("runDocxDiff", () => {
|
|
||||||
test("exit 0: success, returns DifferMeta JSON", async () => {
|
|
||||||
const dir = tempDir();
|
|
||||||
const sourceDocx = join(dir, "original.docx");
|
|
||||||
const modifiedDocx = join(dir, "modified.docx");
|
|
||||||
const diffDocx = join(dir, "diff.docx");
|
|
||||||
writeFileSync(sourceDocx, "");
|
|
||||||
writeFileSync(modifiedDocx, "");
|
|
||||||
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
// simulate docx-diff creating the diff file
|
|
||||||
writeFileSync(diffDocx, "");
|
|
||||||
|
|
||||||
const raw = await runDocxDiff(
|
|
||||||
{ command: "docx-diff" },
|
|
||||||
sourceDocx,
|
|
||||||
modifiedDocx,
|
|
||||||
diffDocx,
|
|
||||||
spawnFn,
|
|
||||||
);
|
|
||||||
const meta = JSON.parse(raw);
|
|
||||||
expect(meta.sourceDocx).toBe(sourceDocx);
|
|
||||||
expect(meta.modifiedDocx).toBe(modifiedDocx);
|
|
||||||
expect(meta.diffDocx).toBe(diffDocx);
|
|
||||||
|
|
||||||
expect(spawnFn.mock.calls[0][1]).toEqual([
|
|
||||||
sourceDocx,
|
|
||||||
modifiedDocx,
|
|
||||||
"--output",
|
|
||||||
"docx",
|
|
||||||
"--out-file",
|
|
||||||
diffDocx,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exit 1 (changes found): treated as success", async () => {
|
|
||||||
const dir = tempDir();
|
|
||||||
const sourceDocx = join(dir, "s.docx");
|
|
||||||
const modifiedDocx = join(dir, "m.docx");
|
|
||||||
const diffDocx = join(dir, "diff.docx");
|
|
||||||
writeFileSync(sourceDocx, "");
|
|
||||||
writeFileSync(modifiedDocx, "");
|
|
||||||
writeFileSync(diffDocx, "");
|
|
||||||
|
|
||||||
const spawnFn = makeSpawn(
|
|
||||||
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
|
|
||||||
).rejects.toThrow("docx-diff failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("timeout: throws error", async () => {
|
|
||||||
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
|
|
||||||
).rejects.toThrow("timed out");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws when diff file not created", async () => {
|
|
||||||
const dir = tempDir();
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
// do NOT create diffDocx
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn),
|
|
||||||
).rejects.toThrow("diff file not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses PATH docx-diff when command is null", async () => {
|
|
||||||
const dir = tempDir();
|
|
||||||
const diffDocx = join(dir, "diff.docx");
|
|
||||||
writeFileSync(diffDocx, "");
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
|
|
||||||
await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn);
|
|
||||||
|
|
||||||
expect(spawnFn.mock.calls[0][0]).toBe("docx-diff");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-agent-docx-diff",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"bun": "./src/index.ts",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^",
|
|
||||||
"@uncaged/workflow-template-document": "workspace:^",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@uncaged/workflow-util": "workspace:^"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { dirname, join } from "node:path";
|
|
||||||
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";
|
|
||||||
|
|
||||||
export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
|
|
||||||
return <T>(_prompt: string, schema: z.ZodType<T>) =>
|
|
||||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
|
||||||
const writerStep = ctx.steps.find((s) => s.role === "writer");
|
|
||||||
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");
|
|
||||||
|
|
||||||
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
|
|
||||||
const raw = await runDocxDiff(config, writerMeta.sourceDocx, writerMeta.outputDocx, diffDocx);
|
|
||||||
|
|
||||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
|
||||||
return { meta, childThread: null };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { createDocxDiffAgent } from "./agent.js";
|
|
||||||
export { packageDescriptor } from "./package-descriptor.js";
|
|
||||||
export type { DocxDiffAgentConfig } from "./types.js";
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
|
||||||
|
|
||||||
export const packageDescriptor: PackageDescriptor = {
|
|
||||||
name: "@uncaged/workflow-agent-docx-diff",
|
|
||||||
version: "0.1.0",
|
|
||||||
capabilities: ["docx-diff-cli", "docx-diff-report"],
|
|
||||||
configSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
command: {
|
|
||||||
anyOf: [{ type: "string" }, { type: "null" }],
|
|
||||||
description: "Path to docx-diff CLI binary; null uses PATH.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { stat } from "node:fs/promises";
|
|
||||||
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;
|
|
||||||
|
|
||||||
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");
|
|
||||||
throw new Error(`docx-diff: spawn failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runDocxDiff(
|
|
||||||
config: DocxDiffAgentConfig,
|
|
||||||
sourceDocx: string,
|
|
||||||
modifiedDocx: string,
|
|
||||||
diffDocx: string,
|
|
||||||
spawnCliFn: SpawnCliFn = spawnCli,
|
|
||||||
): Promise<string> {
|
|
||||||
const command = config.command ?? "docx-diff";
|
|
||||||
const result = await spawnCliFn(
|
|
||||||
command,
|
|
||||||
[sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx],
|
|
||||||
{ cwd: null, timeoutMs: null },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
const e = result.error;
|
|
||||||
// exit 1 = changes found (normal for docx-diff)
|
|
||||||
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
|
|
||||||
// fall through to file check
|
|
||||||
} else {
|
|
||||||
throwSpawnError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stat(diffDocx);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`docx-diff: diff file not found: ${diffDocx}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx });
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export type DocxDiffAgentConfig = {
|
|
||||||
command: string | null;
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [
|
|
||||||
{ "path": "../workflow-protocol" },
|
|
||||||
{ "path": "../workflow-runtime" },
|
|
||||||
{ "path": "../workflow-util-agent" },
|
|
||||||
{ "path": "../workflow-template-document" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# @uncaged/workflow-agent-hermes
|
|
||||||
|
|
||||||
## 0.5.0-alpha.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
|
||||||
|
|
||||||
## 0.5.0-alpha.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
|
||||||
|
|
||||||
## 0.5.0-alpha.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
|
||||||
|
|
||||||
## 0.5.0-alpha.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
|
||||||
|
|
||||||
## 0.5.0-alpha.0
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
|
||||||
|
|
||||||
## 0.4.5
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.4.5
|
|
||||||
- @uncaged/workflow-util-agent@0.4.5
|
|
||||||
|
|
||||||
## 0.4.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.4.4
|
|
||||||
- @uncaged/workflow-util-agent@0.4.4
|
|
||||||
|
|
||||||
## 0.4.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-runtime@0.4.3
|
|
||||||
- @uncaged/workflow-util-agent@0.4.3
|
|
||||||
|
|
||||||
## 0.4.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-runtime@0.4.2
|
|
||||||
- @uncaged/workflow-util-agent@0.4.2
|
|
||||||
|
|
||||||
## 0.4.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- Fix package exports for published packages and adopt changesets for version management.
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-runtime@0.4.0
|
|
||||||
- @uncaged/workflow-util-agent@0.4.0
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# @uncaged/workflow-agent-hermes
|
|
||||||
|
|
||||||
`AgentFn` adapter that runs the `hermes` CLI in non-interactive `chat` mode (Nerve-style flags: `-q`, `--yolo`, `--quiet`, bounded `--max-turns`).
|
|
||||||
|
|
||||||
The agent composes the same thread-aware prompt as other CLI-backed agents via `buildAgentPrompt` from `@uncaged/workflow-util-agent`, then spawns `hermes` and returns stdout on success.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @uncaged/workflow-agent-hermes @uncaged/workflow-runtime @uncaged/workflow-util-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
In this monorepo: use `workspace:*` for `@uncaged/workflow-agent-hermes`, `@uncaged/workflow-runtime`, and `@uncaged/workflow-util-agent`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
|
||||||
|
|
||||||
const agent = createHermesAgent({
|
|
||||||
model: "your-model", // or null to omit --model
|
|
||||||
timeout: 600_000, // ms, or null for no timeout
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## API overview
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `createHermesAgent(config)` | Returns `AgentFn` wrapping `hermes chat -q ...` |
|
|
||||||
| `HermesAgentConfig` | `model`, `timeout` |
|
|
||||||
| `validateHermesAgentConfig` | Config validation result |
|
|
||||||
|
|
||||||
Requires `hermes` on `PATH` at runtime.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-agent-hermes",
|
|
||||||
"version": "0.5.0-alpha.4",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"bun": "./src/index.ts",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
|
|
||||||
import {
|
|
||||||
buildThreadInput,
|
|
||||||
createAgentAdapter,
|
|
||||||
type SpawnCliError,
|
|
||||||
spawnCli,
|
|
||||||
} from "@uncaged/workflow-util-agent";
|
|
||||||
|
|
||||||
import type { HermesAgentConfig } from "./types.js";
|
|
||||||
import { validateHermesAgentConfig } from "./validate-config.js";
|
|
||||||
|
|
||||||
const HERMES_DEFAULT_MAX_TURNS = 90;
|
|
||||||
|
|
||||||
type HermesAgentOpt = { prompt: string };
|
|
||||||
|
|
||||||
export type { HermesAgentConfig } from "./types.js";
|
|
||||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
|
||||||
|
|
||||||
function throwHermesSpawnError(error: SpawnCliError): never {
|
|
||||||
if (error.kind === "non_zero_exit") {
|
|
||||||
throw new Error(
|
|
||||||
`hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error.kind === "timeout") {
|
|
||||||
throw new Error("hermes: timeout");
|
|
||||||
}
|
|
||||||
if (error.kind === "spawn_failed") {
|
|
||||||
throw new Error(`hermes: ${error.message}`);
|
|
||||||
}
|
|
||||||
throw new Error("hermes: unknown spawn error");
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
|
|
||||||
const timeoutMs = config.timeout;
|
|
||||||
|
|
||||||
return async (ctx, { prompt }) => {
|
|
||||||
const threadInput = await buildThreadInput(ctx);
|
|
||||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
|
||||||
const args = [
|
|
||||||
"chat",
|
|
||||||
"-q",
|
|
||||||
fullPrompt,
|
|
||||||
"--yolo",
|
|
||||||
"--max-turns",
|
|
||||||
String(HERMES_DEFAULT_MAX_TURNS),
|
|
||||||
"--quiet",
|
|
||||||
];
|
|
||||||
if (config.model !== null) {
|
|
||||||
args.push("--model", config.model);
|
|
||||||
}
|
|
||||||
const run = await spawnCli(config.command, args, {
|
|
||||||
cwd: null,
|
|
||||||
timeoutMs,
|
|
||||||
});
|
|
||||||
if (!run.ok) {
|
|
||||||
throwHermesSpawnError(run.error);
|
|
||||||
}
|
|
||||||
return run.value;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
|
||||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
|
||||||
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
|
|
||||||
const validated = validateHermesAgentConfig(config);
|
|
||||||
if (!validated.ok) {
|
|
||||||
throw new Error(validated.error);
|
|
||||||
}
|
|
||||||
return { prompt };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export type HermesAgentConfig = {
|
|
||||||
/** Absolute path to the hermes CLI binary. */
|
|
||||||
command: string;
|
|
||||||
model: string | null;
|
|
||||||
timeout: number | null;
|
|
||||||
};
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# @uncaged/workflow-agent-llm
|
|
||||||
|
|
||||||
## 0.5.0-alpha.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
|
||||||
|
|
||||||
## 0.5.0-alpha.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
|
||||||
|
|
||||||
## 0.5.0-alpha.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
|
||||||
|
|
||||||
## 0.5.0-alpha.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
|
||||||
|
|
||||||
## 0.5.0-alpha.0
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
|
||||||
|
|
||||||
## 0.4.5
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.4.5
|
|
||||||
- @uncaged/workflow-util-agent@0.4.5
|
|
||||||
|
|
||||||
## 0.4.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-runtime@0.4.4
|
|
||||||
- @uncaged/workflow-util-agent@0.4.4
|
|
||||||
|
|
||||||
## 0.4.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-runtime@0.4.3
|
|
||||||
- @uncaged/workflow-util-agent@0.4.3
|
|
||||||
|
|
||||||
## 0.4.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-runtime@0.4.2
|
|
||||||
- @uncaged/workflow-util-agent@0.4.2
|
|
||||||
|
|
||||||
## 0.4.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- Fix package exports for published packages and adopt changesets for version management.
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-runtime@0.4.0
|
|
||||||
- @uncaged/workflow-util-agent@0.4.0
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { createOfficeAgent } from "../src/agent.js";
|
|
||||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
|
||||||
|
|
||||||
describe("createOfficeAgent", () => {
|
|
||||||
test("returns an AdapterFn (function)", () => {
|
|
||||||
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("AdapterFn returns a RoleFn (function)", () => {
|
|
||||||
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
|
|
||||||
const roleFn = agent("", expect.anything() as never);
|
|
||||||
expect(typeof roleFn).toBe("function");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("packageDescriptor", () => {
|
|
||||||
test("has correct name", () => {
|
|
||||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-office");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has outputDir in configSchema required", () => {
|
|
||||||
const schema = packageDescriptor.configSchema as { required: string[] };
|
|
||||||
expect(schema.required).toContain("outputDir");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test";
|
|
||||||
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";
|
|
||||||
|
|
||||||
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
|
|
||||||
|
|
||||||
function makeSpawn(result: MockSpawnResult) {
|
|
||||||
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tempDir(): string {
|
|
||||||
const dir = join(tmpdir(), `office-test-${Date.now()}`);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("generateDocument", () => {
|
|
||||||
test("calls office-agent create with correct args and returns outputDocx path", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult);
|
|
||||||
// Simulate CLI creating the file
|
|
||||||
const outFile = join(base, "thread1", "output.docx");
|
|
||||||
mkdirSync(join(base, "thread1"), { recursive: true });
|
|
||||||
writeFileSync(outFile, "");
|
|
||||||
|
|
||||||
const result = await generateDocument(
|
|
||||||
{ outputDir: base, command: "office-agent", timeout: null },
|
|
||||||
"thread1",
|
|
||||||
"Write a report",
|
|
||||||
spawnFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outputDocx).toBe(outFile);
|
|
||||||
expect(result.sourceDocx).toBeNull();
|
|
||||||
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
|
|
||||||
expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]);
|
|
||||||
expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses PATH office-agent when command is null", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
mkdirSync(join(base, "t2"), { recursive: true });
|
|
||||||
writeFileSync(join(base, "t2", "output.docx"), "");
|
|
||||||
|
|
||||||
await generateDocument(
|
|
||||||
{ outputDir: base, command: null, timeout: null },
|
|
||||||
"t2",
|
|
||||||
"Generate",
|
|
||||||
spawnFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws on non_zero_exit", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(
|
|
||||||
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn),
|
|
||||||
).rejects.toThrow("office-agent failed (exit 1)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws on timeout", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn),
|
|
||||||
).rejects.toThrow("office-agent: timed out");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws when output file not created", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
// Do NOT create output.docx
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn),
|
|
||||||
).rejects.toThrow("output file not found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editDocument", () => {
|
|
||||||
test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
// Create a fake inputDocx
|
|
||||||
const inputFile = join(base, "source.docx");
|
|
||||||
writeFileSync(inputFile, "original content");
|
|
||||||
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
// Simulate CLI overwriting modified.docx
|
|
||||||
const outDir = join(base, "te1");
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
writeFileSync(join(outDir, "modified.docx"), "modified content");
|
|
||||||
|
|
||||||
const result = await editDocument(
|
|
||||||
{ outputDir: base, command: "office-agent", timeout: null },
|
|
||||||
"te1",
|
|
||||||
"Edit the doc",
|
|
||||||
inputFile,
|
|
||||||
spawnFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outputDocx).toBe(join(outDir, "modified.docx"));
|
|
||||||
expect(result.sourceDocx).toBe(join(outDir, "original.docx"));
|
|
||||||
expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws on spawn_failed", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const inputFile = join(base, "src.docx");
|
|
||||||
writeFileSync(inputFile, "");
|
|
||||||
const spawnFn = makeSpawn(
|
|
||||||
err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
editDocument(
|
|
||||||
{ outputDir: base, command: null, timeout: null },
|
|
||||||
"te2",
|
|
||||||
"edit",
|
|
||||||
inputFile,
|
|
||||||
spawnFn,
|
|
||||||
),
|
|
||||||
).rejects.toThrow("spawn failed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-agent-office",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"bun": "./src/index.ts",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"@uncaged/workflow-util": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
|
||||||
|
|
||||||
type ParsedInput = { prompt: string; inputDocx: string | null };
|
|
||||||
|
|
||||||
function parseStartInput(content: string): ParsedInput {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
||||||
if (typeof parsed.prompt === "string") {
|
|
||||||
return {
|
|
||||||
prompt: parsed.prompt,
|
|
||||||
inputDocx: typeof parsed.inputDocx === "string" ? parsed.inputDocx : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// not JSON — treat whole content as prompt, generate mode
|
|
||||||
}
|
|
||||||
return { prompt: content, inputDocx: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let raw: string;
|
|
||||||
if (inputDocx === null) {
|
|
||||||
const result = await generateDocument(config, ctx.threadId, prompt);
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
|
||||||
return { meta, childThread: null };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { createOfficeAgent } from "./agent.js";
|
|
||||||
export { packageDescriptor } from "./package-descriptor.js";
|
|
||||||
export type { OfficeAgentConfig } from "./types.js";
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
|
||||||
|
|
||||||
export const packageDescriptor: PackageDescriptor = {
|
|
||||||
name: "@uncaged/workflow-agent-office",
|
|
||||||
version: "0.1.0",
|
|
||||||
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
|
|
||||||
configSchema: {
|
|
||||||
type: "object",
|
|
||||||
required: ["outputDir"],
|
|
||||||
properties: {
|
|
||||||
outputDir: {
|
|
||||||
type: "string",
|
|
||||||
description: "Root directory for workflow outputs; subdirs are created per threadId.",
|
|
||||||
},
|
|
||||||
command: {
|
|
||||||
anyOf: [{ type: "string" }, { type: "null" }],
|
|
||||||
description: "Path to office-agent CLI binary; null uses PATH.",
|
|
||||||
},
|
|
||||||
timeout: {
|
|
||||||
anyOf: [{ type: "number" }, { type: "null" }],
|
|
||||||
description: "Timeout in milliseconds; null means no limit.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
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;
|
|
||||||
|
|
||||||
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");
|
|
||||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertFileExists(path: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await stat(path);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`office-agent: output file not found: ${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateDocument(
|
|
||||||
config: OfficeAgentConfig,
|
|
||||||
threadId: string,
|
|
||||||
prompt: string,
|
|
||||||
spawnCliFn: SpawnCliFn = spawnCli,
|
|
||||||
): Promise<{ outputDocx: string; sourceDocx: null }> {
|
|
||||||
const outputDir = join(config.outputDir, threadId);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
|
||||||
const command = config.command ?? "office-agent";
|
|
||||||
const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], {
|
|
||||||
cwd: outputDir,
|
|
||||||
timeoutMs: config.timeout,
|
|
||||||
});
|
|
||||||
if (!result.ok) throwSpawnError(result.error);
|
|
||||||
const outputDocx = join(outputDir, "output.docx");
|
|
||||||
await assertFileExists(outputDocx);
|
|
||||||
return { outputDocx, sourceDocx: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function editDocument(
|
|
||||||
config: OfficeAgentConfig,
|
|
||||||
threadId: string,
|
|
||||||
prompt: string,
|
|
||||||
inputDocx: string,
|
|
||||||
spawnCliFn: SpawnCliFn = spawnCli,
|
|
||||||
): Promise<{ outputDocx: string; sourceDocx: string }> {
|
|
||||||
const outputDir = join(config.outputDir, threadId);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
|
||||||
const originalDocx = join(outputDir, "original.docx");
|
|
||||||
const modifiedDocx = join(outputDir, "modified.docx");
|
|
||||||
await copyFile(inputDocx, originalDocx);
|
|
||||||
await copyFile(inputDocx, modifiedDocx);
|
|
||||||
const command = config.command ?? "office-agent";
|
|
||||||
const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], {
|
|
||||||
cwd: outputDir,
|
|
||||||
timeoutMs: config.timeout,
|
|
||||||
});
|
|
||||||
if (!result.ok) throwSpawnError(result.error);
|
|
||||||
await assertFileExists(modifiedDocx);
|
|
||||||
return { outputDocx: modifiedDocx, sourceDocx: originalDocx };
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export type OfficeAgentConfig = {
|
|
||||||
outputDir: string;
|
|
||||||
command: string | null;
|
|
||||||
timeout: number | null;
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [
|
|
||||||
{ "path": "../workflow-protocol" },
|
|
||||||
{ "path": "../workflow-runtime" },
|
|
||||||
{ "path": "../workflow-util" },
|
|
||||||
{ "path": "../workflow-util-agent" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# @uncaged/workflow-agent-react
|
|
||||||
|
|
||||||
## 0.5.0-alpha.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [f74b482]
|
|
||||||
- Updated dependencies [f74b482]
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-reactor@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
|
||||||
|
|
||||||
## 0.5.0-alpha.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-reactor@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
|
||||||
|
|
||||||
## 0.5.0-alpha.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-reactor@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
|
||||||
|
|
||||||
## 0.5.0-alpha.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-reactor@0.5.0-alpha.1
|
|
||||||
|
|
||||||
## 0.5.0-alpha.0
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-reactor@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
|
||||||
|
|
||||||
## 0.4.5
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.5
|
|
||||||
- @uncaged/workflow-reactor@0.4.5
|
|
||||||
- @uncaged/workflow-util-agent@0.4.5
|
|
||||||
|
|
||||||
## 0.4.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.4
|
|
||||||
- @uncaged/workflow-reactor@0.4.4
|
|
||||||
- @uncaged/workflow-util-agent@0.4.4
|
|
||||||
|
|
||||||
## 0.4.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.3
|
|
||||||
- @uncaged/workflow-reactor@0.4.3
|
|
||||||
- @uncaged/workflow-util-agent@0.4.3
|
|
||||||
|
|
||||||
## 0.4.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.2
|
|
||||||
- @uncaged/workflow-reactor@0.4.2
|
|
||||||
- @uncaged/workflow-util-agent@0.4.2
|
|
||||||
|
|
||||||
## 0.4.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- Fix package exports for published packages and adopt changesets for version management.
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.0
|
|
||||||
- @uncaged/workflow-reactor@0.4.0
|
|
||||||
- @uncaged/workflow-util-agent@0.4.0
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# @uncaged/workflow-cas
|
|
||||||
|
|
||||||
## 0.5.0-alpha.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- Updated dependencies [f74b482]
|
|
||||||
- Updated dependencies [f74b482]
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
|
||||||
|
|
||||||
## 0.5.0-alpha.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
|
||||||
|
|
||||||
## 0.5.0-alpha.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
|
||||||
|
|
||||||
## 0.5.0-alpha.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
|
||||||
|
|
||||||
## 0.5.0-alpha.0
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
|
||||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
|
||||||
|
|
||||||
## 0.4.5
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.5
|
|
||||||
- @uncaged/workflow-util@0.4.5
|
|
||||||
|
|
||||||
## 0.4.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.4
|
|
||||||
- @uncaged/workflow-util@0.4.4
|
|
||||||
|
|
||||||
## 0.4.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.3
|
|
||||||
- @uncaged/workflow-util@0.4.3
|
|
||||||
|
|
||||||
## 0.4.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.2
|
|
||||||
- @uncaged/workflow-util@0.4.2
|
|
||||||
|
|
||||||
## 0.4.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- Fix package exports for published packages and adopt changesets for version management.
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies
|
|
||||||
- @uncaged/workflow-protocol@0.4.0
|
|
||||||
- @uncaged/workflow-util@0.4.0
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export { createCasStore } from "./cas.js";
|
|
||||||
export { hashWorkflowBundleBytes } from "./hash.js";
|
|
||||||
export {
|
|
||||||
createContentMerkleNode,
|
|
||||||
getContentMerklePayload,
|
|
||||||
putContentMerkleNode,
|
|
||||||
serializeMerkleNode,
|
|
||||||
} from "./merkle.js";
|
|
||||||
export {
|
|
||||||
parseCasThreadNode,
|
|
||||||
putContentNodeWithRefs,
|
|
||||||
putStartNode,
|
|
||||||
putStateNode,
|
|
||||||
} from "./nodes.js";
|
|
||||||
export { findReachableHashes } from "./reachable.js";
|
|
||||||
export type { CasStore } from "./types.js";
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# @uncaged/workflow-dashboard
|
|
||||||
|
|
||||||
Web dashboard for the Uncaged Workflow engine. Connects to the local
|
|
||||||
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start the local API server (in another terminal)
|
|
||||||
uncaged-workflow serve
|
|
||||||
|
|
||||||
# Start the dashboard dev server
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Output goes to `dist/` — static files ready for CF Pages or any host.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Workflow Dashboard</title>
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
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>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-dashboard",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"@xyflow/react": "^12.10.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^1.16.0",
|
|
||||||
"react": "^19.2.6",
|
|
||||||
"react-dom": "^19.2.6",
|
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"react-router": "^7.15.1",
|
|
||||||
"shiki": "^4.0.2",
|
|
||||||
"tailwind-merge": "^3.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"tailwindcss": "^4.2.4",
|
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "^8.0.11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
import { createFilter, type Plugin } from "vite";
|
|
||||||
|
|
||||||
type LimitLineOverride = {
|
|
||||||
files: string;
|
|
||||||
maxReactFCLines: number | null;
|
|
||||||
maxFileLines: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LimitLineOptions = {
|
|
||||||
maxReactFCLines: number;
|
|
||||||
maxFileLines: number;
|
|
||||||
include: RegExp;
|
|
||||||
exclude: RegExp | null;
|
|
||||||
overrides: Array<LimitLineOverride>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: LimitLineOptions = {
|
|
||||||
maxReactFCLines: 300,
|
|
||||||
maxFileLines: 600,
|
|
||||||
include: /\.[tj]sx$/,
|
|
||||||
exclude: null,
|
|
||||||
overrides: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResolvedLimits = {
|
|
||||||
maxReactFCLines: number | null;
|
|
||||||
maxFileLines: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComponentInfo = {
|
|
||||||
name: string;
|
|
||||||
startLine: number;
|
|
||||||
lineCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
|
|
||||||
|
|
||||||
// --- AST types (Rolldown ESTree subset) ---
|
|
||||||
|
|
||||||
type Identifier = {
|
|
||||||
type: "Identifier";
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MemberExpression = {
|
|
||||||
type: "MemberExpression";
|
|
||||||
object: AstExpression;
|
|
||||||
property: Identifier;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CallExpression = {
|
|
||||||
type: "CallExpression";
|
|
||||||
callee: AstExpression;
|
|
||||||
arguments: Array<AstExpression>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AstExpression =
|
|
||||||
| Identifier
|
|
||||||
| MemberExpression
|
|
||||||
| CallExpression
|
|
||||||
| {
|
|
||||||
type: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VariableDeclarator = {
|
|
||||||
id: Identifier | null;
|
|
||||||
init: AstExpression | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AstStatement = {
|
|
||||||
type: string;
|
|
||||||
id: Identifier | null;
|
|
||||||
declaration: AstStatement | null;
|
|
||||||
declarations: Array<VariableDeclarator>;
|
|
||||||
body: Array<AstStatement>;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AstProgram = {
|
|
||||||
type: "Program";
|
|
||||||
body: Array<AstStatement>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- AST helpers ---
|
|
||||||
|
|
||||||
function isFunctionLike(node: AstExpression): boolean {
|
|
||||||
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
|
||||||
}
|
|
||||||
|
|
||||||
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
|
|
||||||
|
|
||||||
function isWrapperCall(node: AstExpression): boolean {
|
|
||||||
if (node.type !== "CallExpression") return false;
|
|
||||||
const call = node as CallExpression;
|
|
||||||
const callee = call.callee;
|
|
||||||
|
|
||||||
if (callee.type === "Identifier") {
|
|
||||||
return WRAPPER_NAMES.has((callee as Identifier).name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (callee.type === "MemberExpression") {
|
|
||||||
const member = callee as MemberExpression;
|
|
||||||
return member.property.type === "Identifier" && WRAPPER_NAMES.has(member.property.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractComponentNames(ast: AstProgram): Array<string> {
|
|
||||||
const names: Array<string> = [];
|
|
||||||
|
|
||||||
for (const node of ast.body) {
|
|
||||||
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
|
|
||||||
names.push(node.id.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === "ExportNamedDeclaration" && node.declaration) {
|
|
||||||
const decl = node.declaration;
|
|
||||||
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
|
|
||||||
names.push(decl.id.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (decl.type === "VariableDeclaration") {
|
|
||||||
collectNamesFromVarDeclaration(decl, names);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === "VariableDeclaration") {
|
|
||||||
collectNamesFromVarDeclaration(node, names);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectNamesFromVarDeclaration(node: AstStatement, names: Array<string>): void {
|
|
||||||
for (const declarator of node.declarations ?? []) {
|
|
||||||
if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue;
|
|
||||||
const init = declarator.init;
|
|
||||||
if (isFunctionLike(init)) {
|
|
||||||
names.push(declarator.id.name);
|
|
||||||
} else if (isWrapperCall(init)) {
|
|
||||||
const args = (init as CallExpression).arguments;
|
|
||||||
if (args.length > 0 && isFunctionLike(args[0])) {
|
|
||||||
names.push(declarator.id.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Source measurement ---
|
|
||||||
|
|
||||||
function measureComponentInSource(name: string, lines: Array<string>): ComponentInfo | null {
|
|
||||||
const fnPattern = new RegExp(`^(?:export\\s+)?function\\s+${name}\\s*[(<]`);
|
|
||||||
const varPattern = new RegExp(`^(?:export\\s+)?const\\s+${name}\\s*[=:]`);
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const trimmed = lines[i].trimStart();
|
|
||||||
const isFnDecl = fnPattern.test(trimmed);
|
|
||||||
const isVarDecl = varPattern.test(trimmed);
|
|
||||||
if (!isFnDecl && !isVarDecl) continue;
|
|
||||||
|
|
||||||
if (isFnDecl) {
|
|
||||||
const result = measureFromParams(i, lines);
|
|
||||||
if (result) return { ...result, name };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const result = measureFromArrow(i, lines);
|
|
||||||
if (result) return { ...result, name };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// function Foo(...) { ... } — skip params via parens, then brace-match the body
|
|
||||||
function measureFromParams(startLine: number, lines: Array<string>): ComponentInfo | null {
|
|
||||||
let parenDepth = 0;
|
|
||||||
let pastParams = false;
|
|
||||||
let braceDepth = 0;
|
|
||||||
|
|
||||||
for (let j = startLine; j < lines.length; j++) {
|
|
||||||
for (const ch of lines[j]) {
|
|
||||||
if (!pastParams) {
|
|
||||||
if (ch === "(") parenDepth++;
|
|
||||||
else if (ch === ")") {
|
|
||||||
parenDepth--;
|
|
||||||
if (parenDepth === 0) pastParams = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (ch === "{") braceDepth++;
|
|
||||||
else if (ch === "}") {
|
|
||||||
braceDepth--;
|
|
||||||
if (braceDepth === 0) {
|
|
||||||
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// const Foo = (...) => { ... } / const Foo = memo((...) => { ... })
|
|
||||||
// Find `=>` first, then brace-match from there to skip type annotations in params
|
|
||||||
function measureFromArrow(startLine: number, lines: Array<string>): ComponentInfo | null {
|
|
||||||
let arrowFound = false;
|
|
||||||
let braceDepth = 0;
|
|
||||||
let foundBrace = false;
|
|
||||||
|
|
||||||
for (let j = startLine; j < lines.length; j++) {
|
|
||||||
const line = lines[j];
|
|
||||||
for (let c = 0; c < line.length; c++) {
|
|
||||||
if (!arrowFound) {
|
|
||||||
if (line[c] === "=" && line[c + 1] === ">") {
|
|
||||||
arrowFound = true;
|
|
||||||
c++;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (line[c] === "{") {
|
|
||||||
braceDepth++;
|
|
||||||
foundBrace = true;
|
|
||||||
} else if (line[c] === "}") {
|
|
||||||
braceDepth--;
|
|
||||||
if (foundBrace && braceDepth === 0) {
|
|
||||||
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Config resolution ---
|
|
||||||
|
|
||||||
function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits {
|
|
||||||
const matchers = options.overrides.map((override) => ({
|
|
||||||
match: createFilter(override.files),
|
|
||||||
maxReactFCLines: override.maxReactFCLines,
|
|
||||||
maxFileLines: override.maxFileLines,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (id: string): ResolvedLimits => {
|
|
||||||
let maxReactFCLines: number | null = options.maxReactFCLines;
|
|
||||||
let maxFileLines: number | null = options.maxFileLines;
|
|
||||||
|
|
||||||
for (const matcher of matchers) {
|
|
||||||
if (matcher.match(id)) {
|
|
||||||
maxReactFCLines = matcher.maxReactFCLines;
|
|
||||||
maxFileLines = matcher.maxFileLines;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { maxReactFCLines, maxFileLines };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldProcess(id: string, options: LimitLineOptions): boolean {
|
|
||||||
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 ?? [],
|
|
||||||
};
|
|
||||||
const resolve = createLimitResolver(options);
|
|
||||||
|
|
||||||
const rawCodeCache = new Map<string, string>();
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "vite-plugin-limit-line:pre",
|
|
||||||
enforce: "pre",
|
|
||||||
|
|
||||||
transform(code, id) {
|
|
||||||
if (!shouldProcess(id, options)) return null;
|
|
||||||
|
|
||||||
rawCodeCache.set(id, code);
|
|
||||||
|
|
||||||
const limits = resolve(id);
|
|
||||||
if (limits.maxFileLines === null) return null;
|
|
||||||
|
|
||||||
const totalLines = code.split("\n").length;
|
|
||||||
if (totalLines > limits.maxFileLines) {
|
|
||||||
this.error(
|
|
||||||
[
|
|
||||||
`[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`,
|
|
||||||
` file: ${id}`,
|
|
||||||
"",
|
|
||||||
"How to fix:",
|
|
||||||
" Split this file into smaller modules — extract related types, helpers,",
|
|
||||||
" or sub-components into separate files and re-export from an index.ts.",
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "vite-plugin-limit-line:fc",
|
|
||||||
|
|
||||||
transform(code, id) {
|
|
||||||
if (!shouldProcess(id, options)) return null;
|
|
||||||
|
|
||||||
const limits = resolve(id);
|
|
||||||
if (limits.maxReactFCLines === null) return null;
|
|
||||||
|
|
||||||
const ast = this.parse(code) as unknown as AstProgram;
|
|
||||||
const componentNames = extractComponentNames(ast);
|
|
||||||
if (componentNames.length === 0) return null;
|
|
||||||
|
|
||||||
const raw = rawCodeCache.get(id) ?? code;
|
|
||||||
rawCodeCache.delete(id);
|
|
||||||
const rawLines = raw.split("\n");
|
|
||||||
|
|
||||||
const maxFCLines = limits.maxReactFCLines;
|
|
||||||
const violations: Array<ComponentInfo> = [];
|
|
||||||
for (const name of componentNames) {
|
|
||||||
const info = measureComponentInSource(name, rawLines);
|
|
||||||
if (info && info.lineCount > maxFCLines) {
|
|
||||||
violations.push(info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (violations.length > 0) {
|
|
||||||
const details = violations
|
|
||||||
.map(
|
|
||||||
(v) =>
|
|
||||||
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${maxFCLines})`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
this.error(
|
|
||||||
[
|
|
||||||
`[vite-limit-line] React component too long in ${id}:`,
|
|
||||||
details,
|
|
||||||
"",
|
|
||||||
"How to fix:",
|
|
||||||
" Break each oversized component into smaller ones. Extract reusable",
|
|
||||||
" sections into child components, move complex logic into custom hooks,",
|
|
||||||
" and keep each component focused on a single responsibility.",
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
|
|
||||||
buildEnd() {
|
|
||||||
rawCodeCache.clear();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { LimitLineOptions, LimitLineOverride };
|
|
||||||
export { viteLimitLinePlugin };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Navigate, Outlet, useParams } from "react-router";
|
|
||||||
import { clearApiKey, hasApiKey } from "./api.ts";
|
|
||||||
import { RunDialog } from "./components/run-dialog.tsx";
|
|
||||||
import { Sidebar } from "./components/sidebar.tsx";
|
|
||||||
import { StatusBar } from "./components/status-bar.tsx";
|
|
||||||
import { useTheme } from "./hooks/use-theme.tsx";
|
|
||||||
|
|
||||||
export function Layout() {
|
|
||||||
const [authed, setAuthed] = useState(hasApiKey());
|
|
||||||
const { client } = useParams();
|
|
||||||
const [showRun, setShowRun] = useState(false);
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
|
|
||||||
if (!authed) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen bg-background">
|
|
||||||
<Sidebar
|
|
||||||
onLogout={() => {
|
|
||||||
clearApiKey();
|
|
||||||
setAuthed(false);
|
|
||||||
}}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={toggleTheme}
|
|
||||||
/>
|
|
||||||
<main className="flex-1 overflow-hidden flex flex-col">
|
|
||||||
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Loader2, Users } from "lucide-react";
|
|
||||||
import { Navigate } from "react-router";
|
|
||||||
import { listClients } from "../api.ts";
|
|
||||||
import { useFetch } from "../hooks.ts";
|
|
||||||
|
|
||||||
export function ClientRedirect() {
|
|
||||||
const { status, data } = useFetch(() => listClients(), []);
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
<p className="text-sm text-muted-foreground">Loading clients...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "ok" && data.length > 0) {
|
|
||||||
return <Navigate to={`/${data[0].name}/threads`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
|
||||||
<Users className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="text-sm font-medium">No client selected</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Select a client from the sidebar to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { AlertCircle, Loader2, Moon, Settings, Sun } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { setApiKey } from "../api.ts";
|
|
||||||
import { useTheme } from "../hooks/use-theme.tsx";
|
|
||||||
import { Button } from "./ui/button.tsx";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card.tsx";
|
|
||||||
import { Input } from "./ui/input.tsx";
|
|
||||||
|
|
||||||
export function LoginPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!key.trim()) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
|
|
||||||
headers: { Authorization: `Bearer ${key.trim()}` },
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
|
||||||
setError("Invalid API key");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
setError(`Server error: ${res.status}`);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(`Connection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setApiKey(key.trim());
|
|
||||||
navigate("/", { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background relative">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-4 right-4 transition-colors duration-200"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
|
||||||
</Button>
|
|
||||||
<Card className="w-full max-w-sm shadow-lg transition-all duration-200 hover:shadow-xl hover:border-primary/30">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-xl tracking-tight">
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
Workflow Dashboard
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Enter your API key to continue</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => setKey(e.target.value)}
|
|
||||||
placeholder="API Key"
|
|
||||||
className="transition-all duration-200"
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs text-destructive flex items-center gap-1.5">
|
|
||||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !key.trim()}
|
|
||||||
className="w-full transition-all duration-200"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Verifying…
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"Login"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import { CheckCircle2, Clock, MessageSquare, Rocket, User, XCircle } from "lucide-react";
|
|
||||||
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
|
|
||||||
import { cn } from "../lib/utils.ts";
|
|
||||||
import { Markdown } from "./markdown.tsx";
|
|
||||||
import { Badge } from "./ui/badge.tsx";
|
|
||||||
import { Card } from "./ui/card.tsx";
|
|
||||||
|
|
||||||
const ROLE_HUES = [262, 210, 35, 150, 330, 180, 15, 280, 55, 195, 345, 120, 240, 75, 305];
|
|
||||||
|
|
||||||
function roleHue(role: string): number {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < role.length; i++) {
|
|
||||||
hash = (hash * 31 + role.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return ROLE_HUES[Math.abs(hash) % ROLE_HUES.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleBadgeStyle(role: string): { backgroundColor: string; borderColor: string } {
|
|
||||||
const hue = roleHue(role);
|
|
||||||
return {
|
|
||||||
backgroundColor: `oklch(0.58 0.12 ${hue} / 0.85)`,
|
|
||||||
borderColor: `oklch(0.58 0.12 ${hue} / 0.25)`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(ts: number | null): string | null {
|
|
||||||
if (ts === null) return null;
|
|
||||||
return new Date(ts).toLocaleTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function StartCard({ record }: { record: ThreadStartRecord }) {
|
|
||||||
return (
|
|
||||||
<Card className="p-4 transition-all duration-200 overflow-hidden relative">
|
|
||||||
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-primary/80 via-primary/40 to-transparent" />
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Rocket className="h-5 w-5 text-primary" />
|
|
||||||
<span className="font-semibold text-foreground">{record.workflow}</span>
|
|
||||||
<Badge variant={record.status === "active" ? "success" : "secondary"}>
|
|
||||||
{record.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{record.prompt !== null && (
|
|
||||||
<div className="mt-2 p-3 rounded-md text-sm border-l-2 border-ring bg-muted/50">
|
|
||||||
<div className="text-xs mb-1 text-muted-foreground flex items-center gap-1">
|
|
||||||
<MessageSquare className="h-3 w-3" />
|
|
||||||
Prompt
|
|
||||||
</div>
|
|
||||||
<Markdown content={record.prompt} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
|
|
||||||
const style = roleBadgeStyle(record.role);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"p-3 text-sm transition-all duration-200 border-l-4",
|
|
||||||
highlighted && "wf-record-card-highlight",
|
|
||||||
)}
|
|
||||||
style={{ borderLeftColor: style.borderColor }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span
|
|
||||||
className="text-xs px-2 py-0.5 rounded font-mono font-medium text-white shadow-sm inline-flex items-center gap-1"
|
|
||||||
style={{ backgroundColor: style.backgroundColor }}
|
|
||||||
>
|
|
||||||
<User className="h-3 w-3" />
|
|
||||||
{record.role}
|
|
||||||
</span>
|
|
||||||
{formatTime(record.timestamp) !== null && (
|
|
||||||
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{formatTime(record.timestamp)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Markdown content={record.content} />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResultCard({ record }: { record: WorkflowResultRecord }) {
|
|
||||||
const success = record.returnCode === 0;
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"p-4 transition-all duration-200 border-l-4",
|
|
||||||
success ? "border-l-success" : "border-l-destructive",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{success ? (
|
|
||||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-5 w-5 text-destructive" />
|
|
||||||
)}
|
|
||||||
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
|
|
||||||
<Badge variant="outline" className="font-mono">
|
|
||||||
exit {record.returnCode}
|
|
||||||
</Badge>
|
|
||||||
{formatTime(record.timestamp) !== null && (
|
|
||||||
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{formatTime(record.timestamp)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Markdown content={record.content} />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecordCardProps = {
|
|
||||||
record: ThreadRecord;
|
|
||||||
highlighted: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RecordCard({ record, highlighted }: RecordCardProps) {
|
|
||||||
switch (record.type) {
|
|
||||||
case "thread-start":
|
|
||||||
return <StartCard record={record} />;
|
|
||||||
case "role":
|
|
||||||
return <RoleMessage record={record} highlighted={highlighted} />;
|
|
||||||
case "workflow-result":
|
|
||||||
return <ResultCard record={record} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { listWorkflows, runThread } from "../api.ts";
|
|
||||||
import { useFetch } from "../hooks.ts";
|
|
||||||
import { Button } from "./ui/button.tsx";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "./ui/dialog.tsx";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
|
|
||||||
import { Textarea } from "./ui/textarea.tsx";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
client: string;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RunDialog({ client, open, onOpenChange }: Props) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const workflows = useFetch(() => listWorkflows(client), [client]);
|
|
||||||
const [workflow, setWorkflow] = useState("");
|
|
||||||
const [prompt, setPrompt] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!workflow || !prompt) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await runThread(client, workflow, prompt);
|
|
||||||
onOpenChange(false);
|
|
||||||
navigate(`/${client}/threads/${result.threadId}`);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Run Thread</DialogTitle>
|
|
||||||
<DialogDescription>Start a new thread on {client}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="run-workflow" className="text-sm block mb-1.5 text-muted-foreground">
|
|
||||||
Workflow
|
|
||||||
</label>
|
|
||||||
<Select value={workflow} onValueChange={setWorkflow}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a workflow..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{workflows.status === "ok" &&
|
|
||||||
workflows.data.workflows.map((w) => (
|
|
||||||
<SelectItem key={w.name} value={w.name}>
|
|
||||||
{w.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="run-prompt" className="text-sm block mb-1.5 text-muted-foreground">
|
|
||||||
Prompt
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
id="run-prompt"
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
placeholder="Enter the task prompt..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={submitting || !workflow || !prompt}>
|
|
||||||
{submitting ? "Starting..." : "Run"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { Loader2, LogOut, Moon, Package, Sun, Zap } from "lucide-react";
|
|
||||||
import { useLocation, useNavigate, useParams } from "react-router";
|
|
||||||
import type { ClientEndpoint } from "../api.ts";
|
|
||||||
import { listClients } from "../api.ts";
|
|
||||||
import { useFetch } from "../hooks.ts";
|
|
||||||
import { cn } from "../lib/utils.ts";
|
|
||||||
import { Button } from "./ui/button.tsx";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
|
|
||||||
import { Separator } from "./ui/separator.tsx";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onLogout: () => void;
|
|
||||||
theme: "light" | "dark";
|
|
||||||
onToggleTheme: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Sidebar({ onLogout, theme, onToggleTheme }: Props) {
|
|
||||||
const { client } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const { status, data } = useFetch(() => listClients(), []);
|
|
||||||
|
|
||||||
const clients: ClientEndpoint[] = status === "ok" ? data : [];
|
|
||||||
|
|
||||||
const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
|
|
||||||
|
|
||||||
const viewItems = [
|
|
||||||
{ key: "threads" as const, label: "Threads", icon: Zap },
|
|
||||||
{ key: "workflows" as const, label: "Workflows", icon: Package },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="w-56 border-r border-border flex flex-col bg-sidebar">
|
|
||||||
<div className="p-4 border-b border-primary/20">
|
|
||||||
<h1 className="text-xl font-bold text-foreground tracking-tight">Workflow</h1>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">Dashboard</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-3 py-3">
|
|
||||||
<label
|
|
||||||
className="block text-xs font-medium mb-1.5 text-muted-foreground"
|
|
||||||
htmlFor="client-select"
|
|
||||||
>
|
|
||||||
Client
|
|
||||||
</label>
|
|
||||||
{status === "loading" ? (
|
|
||||||
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
) : clients.length === 0 ? (
|
|
||||||
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center">
|
|
||||||
No clients online
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={client ?? ""}
|
|
||||||
onValueChange={(name) => {
|
|
||||||
if (name) navigate(`/${name}/${view}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs transition-colors duration-200">
|
|
||||||
<SelectValue placeholder="Select client…" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{clients.map((a) => (
|
|
||||||
<SelectItem key={a.name} value={a.name} className="text-xs">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-2 w-2 rounded-full",
|
|
||||||
a.status === "online" ? "bg-success animate-pulse" : "bg-destructive",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{a.name}
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<nav className="flex-1 p-2 space-y-1">
|
|
||||||
{viewItems.map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.key}
|
|
||||||
variant={view === item.key ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-2 transition-colors duration-200",
|
|
||||||
view === item.key
|
|
||||||
? "text-foreground border-l-2 border-primary rounded-l-none"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (client) navigate(`/${client}/${item.key}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
{item.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="p-2 space-y-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
|
|
||||||
onClick={onToggleTheme}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
||||||
{theme === "dark" ? "Light mode" : "Dark mode"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
|
|
||||||
onClick={onLogout}
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { Loader2, Play, Wifi, WifiOff } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { getClientHealth } from "../api.ts";
|
|
||||||
import { Button } from "./ui/button.tsx";
|
|
||||||
|
|
||||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
client: string | null;
|
|
||||||
onRun: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function StatusIndicator({ status }: { status: HealthStatus }) {
|
|
||||||
if (status === "connected") {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 text-xs text-success transition-colors duration-200">
|
|
||||||
<Wifi className="h-3.5 w-3.5" />
|
|
||||||
Connected
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (status === "reconnecting") {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 text-xs text-warning transition-colors duration-200">
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Reconnecting…
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 text-xs text-destructive transition-colors duration-200">
|
|
||||||
<WifiOff className="h-3.5 w-3.5" />
|
|
||||||
Offline
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBar({ client, onRun }: Props) {
|
|
||||||
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
|
||||||
const wasConnectedRef = useRef(false);
|
|
||||||
|
|
||||||
const checkHealth = useCallback(async () => {
|
|
||||||
if (!client) {
|
|
||||||
setStatus("disconnected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await getClientHealth(client);
|
|
||||||
wasConnectedRef.current = true;
|
|
||||||
setStatus("connected");
|
|
||||||
} catch {
|
|
||||||
if (wasConnectedRef.current) {
|
|
||||||
setStatus("reconnecting");
|
|
||||||
} else {
|
|
||||||
setStatus("disconnected");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
wasConnectedRef.current = false;
|
|
||||||
setStatus("disconnected");
|
|
||||||
checkHealth();
|
|
||||||
const interval = setInterval(checkHealth, 10_000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [checkHealth]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between px-6 py-2 text-xs border-b border-border bg-card/80 backdrop-blur-sm">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{client ? `Client: ${client}` : "No client selected"}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
disabled={!client}
|
|
||||||
onClick={onRun}
|
|
||||||
className="h-7 gap-1.5 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<Play className="h-3.5 w-3.5" />
|
|
||||||
Run Thread
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<StatusIndicator status={status} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import { AlertCircle, ArrowLeft, Layers, Loader2, Pause, Play, X } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router";
|
|
||||||
import {
|
|
||||||
getThread,
|
|
||||||
getWorkflowDescriptor,
|
|
||||||
killThread,
|
|
||||||
pauseThread,
|
|
||||||
resumeThread,
|
|
||||||
type ThreadRecord,
|
|
||||||
type WorkflowDescriptor,
|
|
||||||
} from "../api.ts";
|
|
||||||
import { useFetch } from "../hooks.ts";
|
|
||||||
import { useSSE } from "../use-sse.ts";
|
|
||||||
import { RecordCard } from "./record-card.tsx";
|
|
||||||
import { Badge } from "./ui/badge.tsx";
|
|
||||||
import { Button } from "./ui/button.tsx";
|
|
||||||
import { Card } from "./ui/card.tsx";
|
|
||||||
import { ResizablePanel } from "./ui/resizable-panel.tsx";
|
|
||||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
|
||||||
|
|
||||||
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
|
|
||||||
for (const r of records) {
|
|
||||||
if (r.type === "thread-start") return r.workflow;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
|
|
||||||
const states = new Map<string, NodeState>();
|
|
||||||
const roleRecords = records.filter(
|
|
||||||
(r): r is Extract<ThreadRecord, { type: "role" }> => r.type === "role",
|
|
||||||
);
|
|
||||||
const hasResult = records.some((r) => r.type === "workflow-result");
|
|
||||||
|
|
||||||
for (let i = 0; i < roleRecords.length; i++) {
|
|
||||||
const role = roleRecords[i].role;
|
|
||||||
const isLast = i === roleRecords.length - 1;
|
|
||||||
states.set(role, !hasResult && isLast ? "active" : "completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasStart = records.some((r) => r.type === "thread-start");
|
|
||||||
if (hasStart) {
|
|
||||||
states.set("__start__", "completed");
|
|
||||||
}
|
|
||||||
if (hasResult) {
|
|
||||||
states.set("__end__", "completed");
|
|
||||||
for (const [k, v] of states) {
|
|
||||||
if (v === "active") states.set(k, "completed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return states;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThreadDetail() {
|
|
||||||
const params = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const client = params.client as string;
|
|
||||||
const threadId = params.threadId as string;
|
|
||||||
const sse = useSSE(client, threadId);
|
|
||||||
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
|
|
||||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
|
||||||
const recordsEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
||||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const liveActive = sse.connected && !sse.completed;
|
|
||||||
const records = liveActive
|
|
||||||
? sse.records
|
|
||||||
: status === "ok"
|
|
||||||
? data.records
|
|
||||||
: ([] as typeof sse.records);
|
|
||||||
|
|
||||||
const workflowName = useMemo(() => extractWorkflowName(records), [records]);
|
|
||||||
|
|
||||||
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
|
|
||||||
() =>
|
|
||||||
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(client, workflowName),
|
|
||||||
[client, workflowName],
|
|
||||||
);
|
|
||||||
|
|
||||||
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
|
||||||
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
|
|
||||||
|
|
||||||
const indicesByRole = useMemo(() => {
|
|
||||||
const m = new Map<string, number[]>();
|
|
||||||
for (let i = 0; i < records.length; i++) {
|
|
||||||
const r = records[i];
|
|
||||||
if (r.type === "role") {
|
|
||||||
const list = m.get(r.role) ?? [];
|
|
||||||
list.push(i);
|
|
||||||
m.set(r.role, list);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [records]);
|
|
||||||
|
|
||||||
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
|
||||||
|
|
||||||
const handleGraphNodeClick = useCallback(
|
|
||||||
(nodeId: string) => {
|
|
||||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
|
||||||
|
|
||||||
if (nodeId === "__start__") {
|
|
||||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
|
||||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nodeId === "__end__") {
|
|
||||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const indices = indicesByRole.get(nodeId);
|
|
||||||
if (indices === undefined || indices.length === 0) return;
|
|
||||||
|
|
||||||
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
|
||||||
const idx = indices[cycle % indices.length];
|
|
||||||
clickCycleRef.current.set(nodeId, cycle + 1);
|
|
||||||
|
|
||||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
|
||||||
if (el !== null) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
|
||||||
setHighlightedRole(nodeId);
|
|
||||||
highlightTimerRef.current = setTimeout(() => {
|
|
||||||
setHighlightedRole(null);
|
|
||||||
highlightTimerRef.current = null;
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[nodeStates, indicesByRole],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
|
|
||||||
useEffect(() => {
|
|
||||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}, [records.length]);
|
|
||||||
|
|
||||||
async function handleAction(action: "kill" | "pause" | "resume") {
|
|
||||||
setActionStatus(`${action}ing...`);
|
|
||||||
try {
|
|
||||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
|
||||||
await fn(client, threadId);
|
|
||||||
setActionStatus(null);
|
|
||||||
} catch (e) {
|
|
||||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
|
|
||||||
onClick={() => navigate(`/${client}/threads`)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to threads
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="transition-colors duration-200"
|
|
||||||
onClick={() => handleAction("pause")}
|
|
||||||
>
|
|
||||||
<Pause className="h-3.5 w-3.5 text-warning" />
|
|
||||||
Pause
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="transition-colors duration-200"
|
|
||||||
onClick={() => handleAction("resume")}
|
|
||||||
>
|
|
||||||
<Play className="h-3.5 w-3.5 text-success" />
|
|
||||||
Resume
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="transition-colors duration-200"
|
|
||||||
onClick={() => handleAction("kill")}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 text-destructive" />
|
|
||||||
Kill
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mb-2 font-mono tracking-tight flex items-center gap-2 flex-wrap">
|
|
||||||
<span>{threadId}</span>
|
|
||||||
{sse.connected && !sse.completed && (
|
|
||||||
<Badge variant="success" className="animate-pulse flex items-center gap-1.5">
|
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-success-foreground" />
|
|
||||||
Live
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
{actionStatus && (
|
|
||||||
<Badge variant="secondary" className="mb-4 text-xs font-normal">
|
|
||||||
{actionStatus}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
|
|
||||||
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
|
||||||
<ResizablePanel
|
|
||||||
defaultWidth={360}
|
|
||||||
minWidth={240}
|
|
||||||
maxWidth={560}
|
|
||||||
className={null}
|
|
||||||
style={{
|
|
||||||
position: "sticky",
|
|
||||||
top: 16,
|
|
||||||
height: "calc(100vh - 120px)",
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card className="h-full flex flex-col overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50 border-b border-border">
|
|
||||||
<span className="font-mono flex items-center gap-1.5">
|
|
||||||
<Layers className="h-3.5 w-3.5" />
|
|
||||||
Workflow graph
|
|
||||||
{workflowName !== null && (
|
|
||||||
<span className="ml-2 text-foreground">{workflowName}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="tabular-nums">
|
|
||||||
{descriptor.graph.edges.length} edge
|
|
||||||
{descriptor.graph.edges.length === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<WorkflowGraph
|
|
||||||
graph={descriptor.graph}
|
|
||||||
roles={descriptor.roles}
|
|
||||||
nodeStates={nodeStates}
|
|
||||||
onNodeClick={handleGraphNodeClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</ResizablePanel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{status === "loading" && !liveActive && records.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-3">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
|
||||||
<span className="text-sm">Loading thread...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{status === "error" && !liveActive && (
|
|
||||||
<div className="flex items-center gap-2 py-8 justify-center text-destructive">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
<span className="text-sm">Error: {error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(status === "ok" || liveActive || records.length > 0) && (
|
|
||||||
<div className="border-l-2 border-border ml-2 pl-4 space-y-3">
|
|
||||||
{records.map((r, i) => {
|
|
||||||
const key = `${threadId}-${i}`;
|
|
||||||
if (r.type === "role") {
|
|
||||||
const roleIndices = indicesByRole.get(r.role);
|
|
||||||
const isFirstForRole = roleIndices !== undefined && roleIndices[0] === i;
|
|
||||||
const flash = highlightedRole === r.role;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
data-record-index={i}
|
|
||||||
className="relative"
|
|
||||||
ref={(el) => {
|
|
||||||
if (!isFirstForRole) return;
|
|
||||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
|
||||||
else firstCardByRoleRef.current.delete(r.role);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
|
|
||||||
<RecordCard record={r} highlighted={flash} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={key} data-record-index={i} className="relative">
|
|
||||||
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
|
|
||||||
<RecordCard record={r} highlighted={false} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div ref={recordsEndRef} aria-hidden />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { AlertCircle, Clock, Loader2, Workflow, Zap } from "lucide-react";
|
|
||||||
import { useNavigate, useParams } from "react-router";
|
|
||||||
import { listThreads } from "../api.ts";
|
|
||||||
import { useFetch } from "../hooks.ts";
|
|
||||||
import { Badge } from "./ui/badge.tsx";
|
|
||||||
import { Card } from "./ui/card.tsx";
|
|
||||||
|
|
||||||
function statusVariant(status: string): "success" | "destructive" | "secondary" {
|
|
||||||
if (status === "completed") return "success";
|
|
||||||
if (status === "failed") return "destructive";
|
|
||||||
return "secondary";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThreadList() {
|
|
||||||
const params = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const client = params.client as string;
|
|
||||||
const { status, data, error } = useFetch(() => listThreads(client), [client]);
|
|
||||||
|
|
||||||
if (status === "loading")
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
<p className="text-sm text-muted-foreground">Loading threads...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (status === "error")
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
|
||||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
|
||||||
<p className="text-sm text-destructive">Error: {error}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const threads = [...data.threads].sort((a, b) => {
|
|
||||||
if (!a.startedAt && !b.startedAt) return 0;
|
|
||||||
if (!a.startedAt) return 1;
|
|
||||||
if (!b.startedAt) return -1;
|
|
||||||
return b.startedAt.localeCompare(a.startedAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold tracking-tight mb-4">Threads</h2>
|
|
||||||
{threads.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
|
||||||
<Zap className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="text-sm font-medium">No threads</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Run a workflow to create your first thread.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{threads.map((t) => (
|
|
||||||
<Card
|
|
||||||
key={t.threadId}
|
|
||||||
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
|
|
||||||
onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<code className="font-mono text-sm text-foreground">{t.threadId}</code>
|
|
||||||
{t.status && (
|
|
||||||
<Badge variant={statusVariant(t.status)} className="text-xs">
|
|
||||||
{t.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{t.workflow && (
|
|
||||||
<p className="text-sm mt-1 font-medium text-foreground flex items-center gap-1.5">
|
|
||||||
<Workflow className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{t.workflow}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{t.startedAt && (
|
|
||||||
<p className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{t.startedAt}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
|
||||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "border-transparent bg-primary text-primary-foreground shadow",
|
|
||||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
||||||
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
|
|
||||||
outline: "text-foreground",
|
|
||||||
success: "border-transparent bg-success text-success-foreground shadow",
|
|
||||||
warning: "border-transparent bg-warning text-warning-foreground shadow",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type BadgeProps = HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
||||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import type { ButtonHTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
||||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
||||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
success: "border border-success text-success hover:bg-success/10",
|
|
||||||
warning: "border border-warning text-warning hover:bg-warning/10",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2",
|
|
||||||
sm: "h-8 rounded-md px-3 text-xs",
|
|
||||||
lg: "h-10 rounded-md px-8",
|
|
||||||
icon: "h-9 w-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
|
||||||
const Comp = asChild ? Slot : "button";
|
|
||||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root;
|
|
||||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
|
||||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
|
||||||
|
|
||||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger;
|
|
||||||
const DialogPortal = DialogPrimitive.Portal;
|
|
||||||
const DialogClose = DialogPrimitive.Close;
|
|
||||||
|
|
||||||
function DialogOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DialogPortal>
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
className={cn(
|
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { InputHTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function Input({ className, type, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className={cn(
|
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Input };
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import {
|
|
||||||
type CSSProperties,
|
|
||||||
type PointerEvent as ReactPointerEvent,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
defaultWidth: number;
|
|
||||||
minWidth: number;
|
|
||||||
maxWidth: number;
|
|
||||||
className: string | null;
|
|
||||||
style: CSSProperties | null;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ResizablePanel({
|
|
||||||
defaultWidth,
|
|
||||||
minWidth,
|
|
||||||
maxWidth,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
children,
|
|
||||||
}: Props) {
|
|
||||||
const [width, setWidth] = useState(defaultWidth);
|
|
||||||
const dragging = useRef(false);
|
|
||||||
const startX = useRef(0);
|
|
||||||
const startW = useRef(0);
|
|
||||||
|
|
||||||
const onPointerDown = useCallback(
|
|
||||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dragging.current = true;
|
|
||||||
startX.current = e.clientX;
|
|
||||||
startW.current = width;
|
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
||||||
},
|
|
||||||
[width],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPointerMove = useCallback(
|
|
||||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!dragging.current) return;
|
|
||||||
const delta = e.clientX - startX.current;
|
|
||||||
const next = Math.min(maxWidth, Math.max(minWidth, startW.current + delta));
|
|
||||||
setWidth(next);
|
|
||||||
},
|
|
||||||
[minWidth, maxWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPointerUp = useCallback(() => {
|
|
||||||
dragging.current = false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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"
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-y-0 left-1/2 w-px bg-border opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function ScrollArea({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
|
|
||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
<ScrollBar />
|
|
||||||
<ScrollAreaPrimitive.Corner />
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScrollBar({
|
|
||||||
className,
|
|
||||||
orientation = "vertical",
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
|
||||||
return (
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"flex touch-none select-none transition-colors",
|
|
||||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
|
||||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar };
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root;
|
|
||||||
const SelectGroup = SelectPrimitive.Group;
|
|
||||||
const SelectValue = SelectPrimitive.Value;
|
|
||||||
|
|
||||||
function SelectTrigger({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
className={cn(
|
|
||||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
position = "popper",
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
className={cn(
|
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
position === "popper" &&
|
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function Separator({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
decorative = true,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border",
|
|
||||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Separator };
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full overflow-auto">
|
|
||||||
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
|
||||||
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
|
||||||
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableFooter({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
|
||||||
return (
|
|
||||||
<tfoot
|
|
||||||
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
className={cn(
|
|
||||||
"border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
className={cn(
|
|
||||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
className={cn(
|
|
||||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableCaption({ className, ...props }: HTMLAttributes<HTMLTableCaptionElement>) {
|
|
||||||
return <caption className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { TextareaHTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Textarea };
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider;
|
|
||||||
const Tooltip = TooltipPrimitive.Root;
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
|
||||||
|
|
||||||
function TooltipContent({
|
|
||||||
className,
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipPrimitive.Content
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
ChevronDown,
|
|
||||||
GitBranch,
|
|
||||||
Hash,
|
|
||||||
Layers,
|
|
||||||
Loader2,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router";
|
|
||||||
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
|
|
||||||
import { getWorkflowDetail } from "../api.ts";
|
|
||||||
import { useFetch } from "../hooks.ts";
|
|
||||||
import { cn } from "../lib/utils.ts";
|
|
||||||
import { Markdown } from "./markdown.tsx";
|
|
||||||
import { Button } from "./ui/button.tsx";
|
|
||||||
import { Card } from "./ui/card.tsx";
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible.tsx";
|
|
||||||
import { ResizablePanel } from "./ui/resizable-panel.tsx";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table.tsx";
|
|
||||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
|
||||||
|
|
||||||
const ROLE_BORDER_COLORS = [
|
|
||||||
"border-l-blue-400/60",
|
|
||||||
"border-l-emerald-400/60",
|
|
||||||
"border-l-amber-400/60",
|
|
||||||
"border-l-violet-400/60",
|
|
||||||
"border-l-rose-400/60",
|
|
||||||
"border-l-cyan-400/60",
|
|
||||||
"border-l-orange-400/60",
|
|
||||||
"border-l-teal-400/60",
|
|
||||||
];
|
|
||||||
|
|
||||||
function roleBorderColor(name: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < name.length; i++) {
|
|
||||||
hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return ROLE_BORDER_COLORS[Math.abs(hash) % ROLE_BORDER_COLORS.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function versionCount(detail: WorkflowDetailData): number {
|
|
||||||
return detail.history.length + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SchemaRow = {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
depth: number;
|
|
||||||
prefix: string;
|
|
||||||
isVariantHeader: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveType(prop: Record<string, unknown>): string {
|
|
||||||
if (prop.type === "array") {
|
|
||||||
const items = prop.items as Record<string, unknown> | undefined;
|
|
||||||
if (items !== undefined) {
|
|
||||||
const itemType = String(items.type ?? "unknown");
|
|
||||||
return `${itemType}[]`;
|
|
||||||
}
|
|
||||||
return "array";
|
|
||||||
}
|
|
||||||
return String(prop.type ?? "unknown");
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenSchema(
|
|
||||||
schema: Record<string, unknown>,
|
|
||||||
depth: number,
|
|
||||||
parentPrefix: string,
|
|
||||||
keyPrefix: string,
|
|
||||||
): SchemaRow[] {
|
|
||||||
const rows: SchemaRow[] = [];
|
|
||||||
|
|
||||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
|
||||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
|
||||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
|
||||||
const variant = oneOf[vi];
|
|
||||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
||||||
let variantLabel = `Variant ${vi + 1}`;
|
|
||||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
|
||||||
if (pDef.const !== undefined) {
|
|
||||||
variantLabel = `${pName}: ${String(pDef.const)}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const isLast = vi === oneOf.length - 1;
|
|
||||||
const connector = isLast ? "└" : "├";
|
|
||||||
rows.push({
|
|
||||||
key: `${keyPrefix}variant-${vi}`,
|
|
||||||
name: `${parentPrefix}${connector} ${variantLabel}`,
|
|
||||||
type: "",
|
|
||||||
description: "",
|
|
||||||
depth,
|
|
||||||
prefix: parentPrefix,
|
|
||||||
isVariantHeader: true,
|
|
||||||
});
|
|
||||||
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
|
||||||
const variantRequired = new Set<string>(
|
|
||||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
|
||||||
);
|
|
||||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
|
||||||
if (pDef.const !== undefined) continue;
|
|
||||||
const subRows = flattenProperty(
|
|
||||||
pName,
|
|
||||||
pDef,
|
|
||||||
depth + 1,
|
|
||||||
childPrefix,
|
|
||||||
`${keyPrefix}v${vi}-`,
|
|
||||||
variantRequired,
|
|
||||||
);
|
|
||||||
rows.push(...subRows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
||||||
const required = new Set<string>(
|
|
||||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
|
||||||
);
|
|
||||||
for (const [name, prop] of Object.entries(props)) {
|
|
||||||
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
|
||||||
rows.push(...subRows);
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenProperty(
|
|
||||||
name: string,
|
|
||||||
prop: Record<string, unknown>,
|
|
||||||
depth: number,
|
|
||||||
parentPrefix: string,
|
|
||||||
keyPrefix: string,
|
|
||||||
required: Set<string>,
|
|
||||||
): SchemaRow[] {
|
|
||||||
const rows: SchemaRow[] = [];
|
|
||||||
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
|
||||||
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
|
||||||
if (!required.has(name)) type += "?";
|
|
||||||
const description = String(prop.description ?? "");
|
|
||||||
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
key: `${keyPrefix}${name}`,
|
|
||||||
name: displayName,
|
|
||||||
type,
|
|
||||||
description,
|
|
||||||
depth,
|
|
||||||
prefix: parentPrefix,
|
|
||||||
isVariantHeader: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (prop.type === "object" && prop.properties !== undefined) {
|
|
||||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
|
||||||
rows.push(
|
|
||||||
...flattenSchema(
|
|
||||||
prop as Record<string, unknown>,
|
|
||||||
depth + 1,
|
|
||||||
childPrefix,
|
|
||||||
`${keyPrefix}${name}-`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prop.type === "array") {
|
|
||||||
const items = prop.items as Record<string, unknown> | undefined;
|
|
||||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
|
||||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
|
||||||
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasOneOf) {
|
|
||||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
|
||||||
rows.push(
|
|
||||||
...flattenSchema(
|
|
||||||
prop as Record<string, unknown>,
|
|
||||||
depth + 1,
|
|
||||||
childPrefix,
|
|
||||||
`${keyPrefix}${name}-`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
|
|
||||||
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
|
|
||||||
const [promptOpen, setPromptOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card id={`role-${roleName}`} className={cn("p-4 border-l-4", roleBorderColor(roleName))}>
|
|
||||||
<h4 className="text-sm font-semibold font-mono mb-1 text-foreground flex items-center gap-1.5">
|
|
||||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{roleName}
|
|
||||||
</h4>
|
|
||||||
{role.description !== "" && (
|
|
||||||
<p className="text-xs mb-3 text-muted-foreground">{role.description}</p>
|
|
||||||
)}
|
|
||||||
{role.systemPrompt !== "" && (
|
|
||||||
<Collapsible open={promptOpen} onOpenChange={setPromptOpen} className="mb-3">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="gap-1 h-7 px-2 text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/50 rounded-md transition-all duration-200"
|
|
||||||
>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn("h-3 w-3 transition-transform", promptOpen && "rotate-180")}
|
|
||||||
/>
|
|
||||||
System Prompt
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="mt-1 p-2 rounded-md overflow-y-auto text-xs bg-background border border-border max-h-[300px]">
|
|
||||||
<Markdown content={role.systemPrompt} />
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
{rows.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] uppercase tracking-wider mb-1 font-medium text-muted-foreground">
|
|
||||||
Meta Schema
|
|
||||||
</p>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="text-xs">Field</TableHead>
|
|
||||||
<TableHead className="text-xs">Type</TableHead>
|
|
||||||
<TableHead className="text-xs">Description</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((r) => (
|
|
||||||
<TableRow
|
|
||||||
key={r.key}
|
|
||||||
className={cn(r.isVariantHeader ? "border-b-0" : "", "even:bg-muted/30")}
|
|
||||||
>
|
|
||||||
<TableCell
|
|
||||||
className={cn(
|
|
||||||
"font-mono whitespace-pre text-xs",
|
|
||||||
r.isVariantHeader ? "italic text-muted-foreground" : "text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{r.name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{r.type}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs">
|
|
||||||
{r.description || (r.isVariantHeader ? "" : "—")}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
|
||||||
<pre className="text-[10px] font-mono p-2 rounded-md overflow-x-auto bg-background text-muted-foreground">
|
|
||||||
{JSON.stringify(role.schema, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowDetail() {
|
|
||||||
const params = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const client = params.client as string;
|
|
||||||
const workflowName = params.workflowName as string;
|
|
||||||
const { status, data, error } = useFetch(
|
|
||||||
() => getWorkflowDetail(client, workflowName),
|
|
||||||
[client, workflowName],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
|
|
||||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const detail = status === "ok" ? data : null;
|
|
||||||
const descriptor = detail?.descriptor ?? null;
|
|
||||||
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
|
|
||||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
|
||||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
|
||||||
|
|
||||||
const allLitStates = useMemo(() => {
|
|
||||||
const m = new Map<string, NodeState>();
|
|
||||||
m.set("__start__", "completed");
|
|
||||||
m.set("__end__", "completed");
|
|
||||||
for (const [name] of roleEntries) {
|
|
||||||
m.set(name, "completed");
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [roleEntries]);
|
|
||||||
|
|
||||||
function handleGraphNodeClick(nodeId: string) {
|
|
||||||
const el = document.getElementById(`role-${nodeId}`);
|
|
||||||
if (el === null) return;
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
|
||||||
setHighlightedRole(nodeId);
|
|
||||||
highlightTimerRef.current = setTimeout(() => {
|
|
||||||
setHighlightedRole(null);
|
|
||||||
highlightTimerRef.current = null;
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-all duration-200"
|
|
||||||
onClick={() => navigate(`/${client}/workflows`)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to workflows
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mb-4 font-mono tracking-tight">{workflowName}</h2>
|
|
||||||
|
|
||||||
{status === "loading" && (
|
|
||||||
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
<span>Loading workflow...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{status === "error" && (
|
|
||||||
<div className="flex items-center justify-center gap-2 py-12 text-destructive">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
<span>Error: {error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detail !== null && (
|
|
||||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
|
|
||||||
{hasGraph && (
|
|
||||||
<ResizablePanel
|
|
||||||
defaultWidth={360}
|
|
||||||
minWidth={240}
|
|
||||||
maxWidth={560}
|
|
||||||
className={null}
|
|
||||||
style={{
|
|
||||||
position: "sticky",
|
|
||||||
top: 16,
|
|
||||||
height: "calc(100vh - 160px)",
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card className="h-full flex flex-col overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50">
|
|
||||||
<span className="font-mono flex items-center gap-1.5">
|
|
||||||
<Layers className="h-3.5 w-3.5" />
|
|
||||||
Workflow graph
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<WorkflowGraph
|
|
||||||
graph={descriptor.graph}
|
|
||||||
roles={descriptor.roles}
|
|
||||||
nodeStates={allLitStates}
|
|
||||||
onNodeClick={handleGraphNodeClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</ResizablePanel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 space-y-4">
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="rounded-md bg-muted/30 px-3 py-2 mb-3">
|
|
||||||
<p className="text-sm whitespace-pre-wrap text-foreground">
|
|
||||||
{descriptor !== null && descriptor.description !== ""
|
|
||||||
? descriptor.description
|
|
||||||
: "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-mono">
|
|
||||||
<Hash className="h-3 w-3" />
|
|
||||||
<span className="text-foreground">{detail.hash}</span>
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
|
|
||||||
<GitBranch className="h-3 w-3" />
|
|
||||||
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
{roleEntries.length > 0 && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
|
|
||||||
<User className="h-3 w-3" />
|
|
||||||
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{roleEntries.map(([name, role]) => (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg transition-shadow duration-300",
|
|
||||||
highlightedRole === name && "ring-2 ring-ring",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RoleCard roleName={name} role={role} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
|
||||||
import { Check, Circle } from "lucide-react";
|
|
||||||
import type { RoleNodeData } from "./types.ts";
|
|
||||||
|
|
||||||
function borderColor(state: RoleNodeData["state"]): string {
|
|
||||||
switch (state) {
|
|
||||||
case "completed":
|
|
||||||
return "hsl(var(--success))";
|
|
||||||
case "active":
|
|
||||||
return "hsl(var(--ring))";
|
|
||||||
default:
|
|
||||||
return "hsl(var(--border))";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoleNode(props: NodeProps) {
|
|
||||||
const data = props.data as RoleNodeData;
|
|
||||||
const isActive = data.state === "active";
|
|
||||||
const handleStyle = {
|
|
||||||
background: "hsl(var(--muted-foreground))",
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
border: "none",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${data.state !== "default" ? "cursor-pointer" : ""} ${isActive ? "wf-node-pulse" : ""}`}
|
|
||||||
style={{
|
|
||||||
width: 180,
|
|
||||||
height: 60,
|
|
||||||
background: "hsl(var(--card))",
|
|
||||||
borderColor: borderColor(data.state),
|
|
||||||
color: "hsl(var(--foreground))",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
title={data.description}
|
|
||||||
>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Top}
|
|
||||||
id="top-in"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
id="left-in"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Right}
|
|
||||||
id="right-in"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Left}
|
|
||||||
id="left-out"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="right-out"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-1.5 font-mono">
|
|
||||||
{data.state === "completed" && <Check className="h-3 w-3 text-success" />}
|
|
||||||
{data.state === "active" && <Circle className="h-3 w-3 fill-current text-ring" />}
|
|
||||||
<span className="truncate">{data.label}</span>
|
|
||||||
</div>
|
|
||||||
{data.description !== "" && (
|
|
||||||
<div
|
|
||||||
className="text-[10px] truncate mt-0.5"
|
|
||||||
style={{ color: "hsl(var(--muted-foreground))" }}
|
|
||||||
>
|
|
||||||
{data.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id="bottom-out"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
|
||||||
import { Play, Square } from "lucide-react";
|
|
||||||
import type { TerminalNodeData } from "./types.ts";
|
|
||||||
|
|
||||||
function borderColor(state: TerminalNodeData["state"]): string {
|
|
||||||
switch (state) {
|
|
||||||
case "completed":
|
|
||||||
return "hsl(var(--success))";
|
|
||||||
case "active":
|
|
||||||
return "hsl(var(--ring))";
|
|
||||||
default:
|
|
||||||
return "hsl(var(--border))";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bgColor(state: TerminalNodeData["state"]): string {
|
|
||||||
if (state === "completed") return "hsl(var(--success))";
|
|
||||||
if (state === "active") return "hsl(var(--ring))";
|
|
||||||
return "hsl(var(--card))";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TerminalNode(props: NodeProps) {
|
|
||||||
const data = props.data as TerminalNodeData;
|
|
||||||
const isStart = data.kind === "start";
|
|
||||||
const isActive = data.state === "active";
|
|
||||||
const handleStyle = {
|
|
||||||
background: "hsl(var(--muted-foreground))",
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
border: "none",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-full border-2 flex items-center justify-center ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
background: bgColor(data.state),
|
|
||||||
borderColor: borderColor(data.state),
|
|
||||||
color:
|
|
||||||
data.state === "default"
|
|
||||||
? "hsl(var(--muted-foreground))"
|
|
||||||
: "hsl(var(--primary-foreground))",
|
|
||||||
}}
|
|
||||||
title={isStart ? "Start" : "End"}
|
|
||||||
>
|
|
||||||
{isStart ? (
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id="bottom-out"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Top}
|
|
||||||
id="top-in"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
id="left-in"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Right}
|
|
||||||
id="right-in"
|
|
||||||
style={handleStyle}
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isStart ? <Play className="h-3 w-3" /> : <Square className="h-3 w-3" />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
import type { Edge, Node } from "@xyflow/react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import type { WorkflowGraphEdge } from "../../api.ts";
|
|
||||||
import type { NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
|
||||||
|
|
||||||
const START_ID = "__start__";
|
|
||||||
const END_ID = "__end__";
|
|
||||||
const ROLE_NODE_WIDTH = 180;
|
|
||||||
const ROLE_NODE_HEIGHT = 60;
|
|
||||||
const TERMINAL_NODE_SIZE = 40;
|
|
||||||
|
|
||||||
// Vertical gap between nodes in the spine
|
|
||||||
const LAYER_GAP = 80;
|
|
||||||
// Horizontal offset for feedback (back) edges routed on the right side
|
|
||||||
const FEEDBACK_OFFSET_X = 80;
|
|
||||||
|
|
||||||
type LayoutInput = {
|
|
||||||
edges: readonly WorkflowGraphEdge[];
|
|
||||||
roles: Record<string, { description: string }>;
|
|
||||||
nodeStates: Map<string, NodeState>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LayoutResult = {
|
|
||||||
nodes: Node[];
|
|
||||||
edges: Edge[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function nodeSize(id: string): { width: number; height: number } {
|
|
||||||
if (id === START_ID || id === END_ID) {
|
|
||||||
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
|
|
||||||
}
|
|
||||||
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
|
|
||||||
}
|
|
||||||
|
|
||||||
function edgeKey(e: WorkflowGraphEdge): string {
|
|
||||||
return `${e.from}->${e.to}::${e.condition}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const e of edges) {
|
|
||||||
ids.add(e.from);
|
|
||||||
ids.add(e.to);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectBackEdges(ids: Set<string>, edges: readonly WorkflowGraphEdge[]): Set<string> {
|
|
||||||
const WHITE = 0;
|
|
||||||
const GRAY = 1;
|
|
||||||
const BLACK = 2;
|
|
||||||
const backEdges = new Set<string>();
|
|
||||||
const color = new Map<string, number>();
|
|
||||||
for (const id of ids) color.set(id, WHITE);
|
|
||||||
|
|
||||||
const fullAdj = new Map<string, string[]>();
|
|
||||||
for (const id of ids) fullAdj.set(id, []);
|
|
||||||
for (const e of edges) {
|
|
||||||
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dfs(u: string): void {
|
|
||||||
color.set(u, GRAY);
|
|
||||||
for (const v of fullAdj.get(u) ?? []) {
|
|
||||||
const c = color.get(v) ?? WHITE;
|
|
||||||
if (c === GRAY) {
|
|
||||||
backEdges.add(`${u}->${v}`);
|
|
||||||
} else if (c === WHITE) {
|
|
||||||
dfs(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
color.set(u, BLACK);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ids.has(START_ID)) dfs(START_ID);
|
|
||||||
for (const id of ids) {
|
|
||||||
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
|
||||||
}
|
|
||||||
return backEdges;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDagAdjacency(
|
|
||||||
ids: Set<string>,
|
|
||||||
edges: readonly WorkflowGraphEdge[],
|
|
||||||
backEdges: Set<string>,
|
|
||||||
): Map<string, string[]> {
|
|
||||||
const adj = new Map<string, string[]>();
|
|
||||||
for (const id of ids) adj.set(id, []);
|
|
||||||
for (const e of edges) {
|
|
||||||
if (e.from === e.to) continue;
|
|
||||||
if (backEdges.has(`${e.from}->${e.to}`)) continue;
|
|
||||||
adj.get(e.from)?.push(e.to);
|
|
||||||
}
|
|
||||||
return adj;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeInDegrees(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
|
||||||
const inDegree = new Map<string, number>();
|
|
||||||
for (const id of ids) inDegree.set(id, 0);
|
|
||||||
for (const id of ids) {
|
|
||||||
for (const next of adj.get(id) ?? []) {
|
|
||||||
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return inDegree;
|
|
||||||
}
|
|
||||||
|
|
||||||
function relaxLongestPathNeighbors(
|
|
||||||
cur: string,
|
|
||||||
curRank: number,
|
|
||||||
adj: Map<string, string[]>,
|
|
||||||
rank: Map<string, number>,
|
|
||||||
inDegree: Map<string, number>,
|
|
||||||
queue: string[],
|
|
||||||
): void {
|
|
||||||
for (const next of adj.get(cur) ?? []) {
|
|
||||||
const prevRank = rank.get(next) ?? 0;
|
|
||||||
if (curRank + 1 > prevRank) rank.set(next, curRank + 1);
|
|
||||||
const deg = (inDegree.get(next) ?? 1) - 1;
|
|
||||||
inDegree.set(next, deg);
|
|
||||||
if (deg === 0) queue.push(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function longestPathRanks(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
|
||||||
const inDegree = computeInDegrees(ids, adj);
|
|
||||||
const rank = new Map<string, number>();
|
|
||||||
const queue: string[] = [];
|
|
||||||
for (const id of ids) {
|
|
||||||
if ((inDegree.get(id) ?? 0) === 0) {
|
|
||||||
queue.push(id);
|
|
||||||
rank.set(id, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const cur = queue.shift();
|
|
||||||
if (cur === undefined) break;
|
|
||||||
relaxLongestPathNeighbors(cur, rank.get(cur) ?? 0, adj, rank, inDegree, queue);
|
|
||||||
}
|
|
||||||
return rank;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareLayerNodes(a: string, b: string): number {
|
|
||||||
if (a === START_ID) return -1;
|
|
||||||
if (b === START_ID) return 1;
|
|
||||||
if (a === END_ID) return 1;
|
|
||||||
if (b === END_ID) return -1;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ranksToLayers(rank: Map<string, number>): string[][] {
|
|
||||||
const maxRank = Math.max(...[...rank.values()], 0);
|
|
||||||
const layers: string[][] = [];
|
|
||||||
for (let r = 0; r <= maxRank; r++) layers.push([]);
|
|
||||||
for (const [id, r] of rank) layers[r].push(id);
|
|
||||||
for (const layer of layers) layer.sort(compareLayerNodes);
|
|
||||||
return layers.filter((l) => l.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Strategy 1: Longest-path layering (Sugiyama step 1) ─────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign layers via longest path from sources.
|
|
||||||
*
|
|
||||||
* For each node, rank = max(rank(pred) + 1) over all predecessors.
|
|
||||||
* This guarantees that if a -> b (and not b -> a), rank(a) < rank(b).
|
|
||||||
*
|
|
||||||
* Back-edges (cycles) are detected and excluded from ranking:
|
|
||||||
* we first remove edges that create cycles (DFS-based), compute ranks
|
|
||||||
* on the resulting DAG, then the removed edges become feedback edges.
|
|
||||||
*/
|
|
||||||
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
|
|
||||||
const ids = collectNodeIds(edges);
|
|
||||||
const backEdges = detectBackEdges(ids, edges);
|
|
||||||
const adj = buildDagAdjacency(ids, edges, backEdges);
|
|
||||||
const rank = longestPathRanks(ids, adj);
|
|
||||||
return ranksToLayers(rank);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildRoleNode(
|
|
||||||
id: string,
|
|
||||||
pos: { x: number; y: number },
|
|
||||||
roles: Record<string, { description: string }>,
|
|
||||||
state: NodeState,
|
|
||||||
): Node<RoleNodeData> {
|
|
||||||
const description = roles[id]?.description ?? "";
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: "role",
|
|
||||||
position: pos,
|
|
||||||
data: { label: id, description, state },
|
|
||||||
draggable: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTerminalNode(
|
|
||||||
id: string,
|
|
||||||
pos: { x: number; y: number },
|
|
||||||
state: NodeState,
|
|
||||||
): Node<TerminalNodeData> {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: "terminal",
|
|
||||||
position: pos,
|
|
||||||
data: { kind: id === START_ID ? "start" : "end", state },
|
|
||||||
draggable: false,
|
|
||||||
selectable: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type EdgeLayoutContext = {
|
|
||||||
rank: Map<string, number>;
|
|
||||||
nodePositions: Map<string, { x: number; y: number; w: number; h: number }>;
|
|
||||||
centerX: number;
|
|
||||||
routedCountByTarget: Map<string, number>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function computeEdgeLabelPosition(
|
|
||||||
e: WorkflowGraphEdge,
|
|
||||||
ctx: EdgeLayoutContext,
|
|
||||||
isFeedback: boolean,
|
|
||||||
isSkipForward: boolean,
|
|
||||||
isSelfLoop: boolean,
|
|
||||||
): { labelX: number | null; labelY: number | null; feedbackSide: "right" | "left" | null } {
|
|
||||||
const sourcePos = ctx.nodePositions.get(e.from);
|
|
||||||
const targetPos = ctx.nodePositions.get(e.to);
|
|
||||||
if (sourcePos === undefined || targetPos === undefined) {
|
|
||||||
return { labelX: null, labelY: null, feedbackSide: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFeedback || isSkipForward) {
|
|
||||||
const count = ctx.routedCountByTarget.get(e.to) ?? 0;
|
|
||||||
ctx.routedCountByTarget.set(e.to, count + 1);
|
|
||||||
const feedbackSide = count % 2 === 0 ? "right" : "left";
|
|
||||||
const offsetX =
|
|
||||||
feedbackSide === "right"
|
|
||||||
? ctx.centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
|
||||||
: ctx.centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
|
||||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
|
||||||
return { labelX: offsetX, labelY: midY, feedbackSide };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelfLoop) {
|
|
||||||
return { labelX: null, labelY: null, feedbackSide: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
|
||||||
return { labelX: ctx.centerX, labelY: midY, feedbackSide: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConditionEdge(e: WorkflowGraphEdge, ctx: EdgeLayoutContext): Edge {
|
|
||||||
const isFallback = e.condition === "FALLBACK";
|
|
||||||
const isSelfLoop = e.from === e.to;
|
|
||||||
const sourceRank = ctx.rank.get(e.from) ?? 0;
|
|
||||||
const targetRank = ctx.rank.get(e.to) ?? 0;
|
|
||||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
|
||||||
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
|
|
||||||
const routed = isFeedback || isSkipForward;
|
|
||||||
|
|
||||||
const { labelX, labelY, feedbackSide } = computeEdgeLabelPosition(
|
|
||||||
e,
|
|
||||||
ctx,
|
|
||||||
isFeedback,
|
|
||||||
isSkipForward,
|
|
||||||
isSelfLoop,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: edgeKey(e),
|
|
||||||
source: e.from,
|
|
||||||
target: e.to,
|
|
||||||
sourceHandle: routed ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
|
|
||||||
targetHandle: routed ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
|
||||||
type: "condition",
|
|
||||||
data: {
|
|
||||||
condition: e.condition,
|
|
||||||
conditionDescription: e.conditionDescription,
|
|
||||||
isFallback,
|
|
||||||
isFeedback: routed,
|
|
||||||
isSelfLoop,
|
|
||||||
feedbackSide,
|
|
||||||
labelX,
|
|
||||||
labelY,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const LAYER_H_GAP = 40;
|
|
||||||
|
|
||||||
type NodePosition = { x: number; y: number; w: number; h: number };
|
|
||||||
|
|
||||||
function layerIndexRank(layers: string[][]): Map<string, number> {
|
|
||||||
const rank = new Map<string, number>();
|
|
||||||
for (let i = 0; i < layers.length; i++) {
|
|
||||||
for (const id of layers[i]) rank.set(id, i);
|
|
||||||
}
|
|
||||||
return rank;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeLayerWidths(layers: string[][], hGap: number): number[] {
|
|
||||||
return layers.map((layer) => {
|
|
||||||
let w = 0;
|
|
||||||
for (const id of layer) w += nodeSize(id).width;
|
|
||||||
return w + (layer.length - 1) * hGap;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function layoutNodePositions(
|
|
||||||
layers: string[][],
|
|
||||||
layerWidths: number[],
|
|
||||||
centerX: number,
|
|
||||||
hGap: number,
|
|
||||||
): Map<string, NodePosition> {
|
|
||||||
const nodePositions = new Map<string, NodePosition>();
|
|
||||||
let y = 0;
|
|
||||||
for (let li = 0; li < layers.length; li++) {
|
|
||||||
const layer = layers[li];
|
|
||||||
let x = centerX - layerWidths[li] / 2;
|
|
||||||
let maxH = 0;
|
|
||||||
for (const id of layer) {
|
|
||||||
const size = nodeSize(id);
|
|
||||||
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
|
||||||
x += size.width + hGap;
|
|
||||||
if (size.height > maxH) maxH = size.height;
|
|
||||||
}
|
|
||||||
y += maxH + LAYER_GAP;
|
|
||||||
}
|
|
||||||
return nodePositions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLayoutNodes(
|
|
||||||
layers: string[][],
|
|
||||||
nodePositions: Map<string, NodePosition>,
|
|
||||||
input: LayoutInput,
|
|
||||||
): Node[] {
|
|
||||||
const nodes: Node[] = [];
|
|
||||||
for (const layer of layers) {
|
|
||||||
for (const id of layer) {
|
|
||||||
const pos = nodePositions.get(id);
|
|
||||||
if (pos === undefined) continue;
|
|
||||||
const state = input.nodeStates.get(id) ?? "default";
|
|
||||||
const xy = { x: pos.x, y: pos.y };
|
|
||||||
if (id === START_ID || id === END_ID) {
|
|
||||||
nodes.push(buildTerminalNode(id, xy, state));
|
|
||||||
} else {
|
|
||||||
nodes.push(buildRoleNode(id, xy, input.roles, state));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Longest-path layout (uses same edge-building as before) ─────────
|
|
||||||
|
|
||||||
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
|
||||||
const layers = computeLayersLongestPath(input.edges);
|
|
||||||
const rank = layerIndexRank(layers);
|
|
||||||
const layerWidths = computeLayerWidths(layers, LAYER_H_GAP);
|
|
||||||
const centerX = Math.max(...layerWidths, ROLE_NODE_WIDTH) / 2;
|
|
||||||
const nodePositions = layoutNodePositions(layers, layerWidths, centerX, LAYER_H_GAP);
|
|
||||||
const nodes = buildLayoutNodes(layers, nodePositions, input);
|
|
||||||
const edgeCtx: EdgeLayoutContext = {
|
|
||||||
rank,
|
|
||||||
nodePositions,
|
|
||||||
centerX,
|
|
||||||
routedCountByTarget: new Map<string, number>(),
|
|
||||||
};
|
|
||||||
const edges: Edge[] = input.edges.map((e) => buildConditionEdge(e, edgeCtx));
|
|
||||||
return { nodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public hook ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function useLayout(input: LayoutInput): LayoutResult {
|
|
||||||
return useMemo(() => computeLayoutLongestPath(input), [input]);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user