Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e42555fd9c | |||
| 3a26eb28e5 | |||
| c1a17b707c | |||
| 4ea1e0d8a4 | |||
| b1a9d2ec3f | |||
| 2b8707a706 | |||
| 241bfbf6d9 | |||
| 40530d757e | |||
| 0f3661b566 | |||
| 9c44c709e9 | |||
| 8892ab9978 | |||
| 7ec86d82a3 | |||
| f728b36e8d | |||
| 3431d3070b | |||
| 576df067d4 | |||
| a46a225d04 | |||
| f74b482cc0 | |||
| 89abfdc257 | |||
| 77e395b913 | |||
| b65a006d45 | |||
| 5994548f0b | |||
| 0871ae54ea | |||
| 9576d69032 | |||
| 64dadf114d | |||
| baaa1d1dc8 | |||
| 3074cd5f0c | |||
| 15edc99c72 | |||
| 153178c545 | |||
| fac215bd21 | |||
| 9822e68c55 | |||
| 764b73209e | |||
| e7987c4cd7 | |||
| 942ff4b1a4 | |||
| f5977c46c6 | |||
| 71ccf8d03c | |||
| 510b49287a | |||
| bb6b309efa | |||
| 56db22a908 | |||
| 2a1b7b0aeb | |||
| d037eca4ae | |||
| b9d543a465 | |||
| 07f52594d1 | |||
| c7b426ff5a | |||
| 4582274ba4 | |||
| d140801337 | |||
| 4563f1bb5e | |||
| 59b7e89028 | |||
| 019d8c1ee9 | |||
| 5e783e7a24 | |||
| a450a88b16 | |||
| 5b47317cef | |||
| 3384c38d02 | |||
| b370d96504 | |||
| 8cae114c7e | |||
| c2c6fc5304 | |||
| 94f725c50b | |||
| 9b23e6f85a | |||
| 238a94f7a6 | |||
| 236c771e4e | |||
| 0ffd84cf7d | |||
| e14643a50b | |||
| 76830c5e22 | |||
| 90a388f5ab | |||
| 82e40f0c21 | |||
| 8d650326db | |||
| dd3eec7d35 | |||
| 9276689cb6 | |||
| b4584cbaa6 | |||
| 1cf963a1fb | |||
| ce5bc50210 | |||
| 439e203113 | |||
| 522afdd4bd | |||
| ca644dabaa | |||
| 9d9c00df98 | |||
| a1c5dc3e92 | |||
| c85980f604 | |||
| eff5fb332a | |||
| 658a4a24ef | |||
| aabfd90a87 | |||
| 0207f93303 | |||
| e1423f196b | |||
| ae6954a02f | |||
| aede8f7613 | |||
| 6d1e0498ba | |||
| 6cce5e2593 | |||
| d3a7ed9062 | |||
| e7f733c393 | |||
| d4bb4a9324 | |||
| 39540d9ae8 |
@@ -0,0 +1,8 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-util": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Replace optionalEnv/requireEnv with unified env(name, fallback) API
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-protocol": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: correct internal dependency versions for prerelease
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-util-agent": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: include create-agent-adapter.ts in published src
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-protocol": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"mode": "pre",
|
||||||
|
"tag": "alpha",
|
||||||
|
"initialVersions": {
|
||||||
|
"@uncaged/cli-workflow": "0.4.5",
|
||||||
|
"@uncaged/workflow-agent-cursor": "0.4.5",
|
||||||
|
"@uncaged/workflow-agent-hermes": "0.4.5",
|
||||||
|
"@uncaged/workflow-agent-llm": "0.4.5",
|
||||||
|
"@uncaged/workflow-agent-react": "0.4.5",
|
||||||
|
"@uncaged/workflow-cas": "0.4.5",
|
||||||
|
"@uncaged/workflow-dashboard": "0.1.0",
|
||||||
|
"@uncaged/workflow-execute": "0.4.5",
|
||||||
|
"@uncaged/workflow-gateway": "0.4.5",
|
||||||
|
"@uncaged/workflow-protocol": "0.4.5",
|
||||||
|
"@uncaged/workflow-reactor": "0.4.5",
|
||||||
|
"@uncaged/workflow-register": "0.4.5",
|
||||||
|
"@uncaged/workflow-runtime": "0.4.5",
|
||||||
|
"@uncaged/workflow-template-develop": "0.4.5",
|
||||||
|
"@uncaged/workflow-template-solve-issue": "0.4.5",
|
||||||
|
"@uncaged/workflow-util": "0.4.5",
|
||||||
|
"@uncaged/workflow-util-agent": "0.4.5"
|
||||||
|
},
|
||||||
|
"changesets": [
|
||||||
|
"env-api-unify",
|
||||||
|
"fix-internal-deps",
|
||||||
|
"fix-publish-src",
|
||||||
|
"fix-workspace-deps",
|
||||||
|
"rfc-252-agent-fn"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-protocol": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Workflow Engine — Environment Variables
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Copy this file to .env and fill in the values.
|
||||||
|
|
||||||
|
# ── Cursor Agent ──
|
||||||
|
|
||||||
|
# CLI command to invoke the Cursor agent (required for develop workflow)
|
||||||
|
WORKFLOW_CURSOR_COMMAND=
|
||||||
|
|
||||||
|
# Model override for Cursor agent
|
||||||
|
WORKFLOW_CURSOR_MODEL=
|
||||||
|
|
||||||
|
# Timeout in milliseconds for Cursor agent operations
|
||||||
|
WORKFLOW_CURSOR_TIMEOUT=
|
||||||
|
|
||||||
|
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
|
||||||
|
|
||||||
|
# CLI command to invoke the Hermes agent (absolute path required)
|
||||||
|
WORKFLOW_HERMES_COMMAND=
|
||||||
|
|
||||||
|
# Model override for Hermes agent
|
||||||
|
WORKFLOW_HERMES_MODEL=
|
||||||
|
|
||||||
|
# Timeout in milliseconds for Hermes agent operations
|
||||||
|
WORKFLOW_HERMES_TIMEOUT=
|
||||||
|
|
||||||
|
# ── Storage ──
|
||||||
|
|
||||||
|
# Override the workflow storage root directory
|
||||||
|
# Default: ~/.uncaged/workflow
|
||||||
|
WORKFLOW_STORAGE_ROOT=
|
||||||
|
|
||||||
|
# Gateway secret for the serve command
|
||||||
|
WORKFLOW_DASHBOARD_SECRET=
|
||||||
|
|
||||||
|
# ── Display ──
|
||||||
|
|
||||||
|
# Set to any value to disable colored output
|
||||||
|
# NO_COLOR=1
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/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!"
|
||||||
@@ -6,3 +6,6 @@ tsconfig.tsbuildinfo
|
|||||||
.npmrc
|
.npmrc
|
||||||
|
|
||||||
bunfig.toml
|
bunfig.toml
|
||||||
|
xiaoju/
|
||||||
|
solve-issue-entry.ts
|
||||||
|
packages/workflow-template-develop/develop.esm.js
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ workflow/
|
|||||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||||
|
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||||
@@ -40,7 +41,7 @@ workflow/
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `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
|
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||||
|
|
||||||
## Language & Paradigm
|
## Language & Paradigm
|
||||||
|
|
||||||
@@ -245,61 +246,47 @@ bun run format # biome format --write
|
|||||||
bun test # run tests
|
bun test # run tests
|
||||||
```
|
```
|
||||||
|
|
||||||
### Publishing to Gitea npm Registry
|
### Version Management & Publishing
|
||||||
|
|
||||||
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`.
|
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Publish all packages (bun pm pack resolves workspace:* → actual versions)
|
# 1. After making changes, add a changeset describing the change
|
||||||
bun run publish:gitea
|
bun changeset
|
||||||
|
|
||||||
# Dry run — see what would be published
|
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||||
bun run publish:gitea:dry
|
bun version
|
||||||
|
|
||||||
|
# 3. Build, test, and publish to npmjs
|
||||||
|
bun release
|
||||||
```
|
```
|
||||||
|
|
||||||
Prerequisites: `.npmrc` in monorepo root with Gitea auth token (`//git.shazhou.work/api/packages/shazhou/npm/:_authToken=<token>`).
|
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||||
|
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||||
|
- Each package has auto-generated `CHANGELOG.md`
|
||||||
|
|
||||||
### Workflow Workspace Setup
|
### Consuming @uncaged/* Packages
|
||||||
|
|
||||||
External workflow repos (e.g. `xingyue-workflows`) use the Gitea registry for `@uncaged/*` packages. Add a `bunfig.toml`:
|
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||||
|
|
||||||
```toml
|
|
||||||
[install.scopes]
|
|
||||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then `bun install` resolves `@uncaged/*` from Gitea, all other packages from npmjs.
|
|
||||||
|
|
||||||
### Cross-repo Development (bun link)
|
|
||||||
|
|
||||||
Alternative for development against un-published local changes:
|
|
||||||
|
|
||||||
```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
|
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||||
|
|
||||||
The recommended development flow for building workflows:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||||
│ bun run publish:gitea — auto topo-sort, bun pm pack → npm publish
|
│ bun release — build + test + changeset publish
|
||||||
▼
|
▼
|
||||||
git.shazhou.work npm registry — @uncaged/* scoped packages
|
npmjs.org — @uncaged/* scoped packages (public)
|
||||||
│ bun install — via bunfig.toml scoped registry
|
│ bun install
|
||||||
▼
|
▼
|
||||||
my-workflows/ (workspace) — bunfig.toml + normal package.json
|
my-workflows/ (workspace) — normal package.json
|
||||||
│ bun run build:develop — bun build → single .esm.js
|
│ bun run build:develop — bun build → single .esm.js
|
||||||
▼
|
▼
|
||||||
uncaged-workflow workflow add — register bundle locally
|
uncaged-workflow workflow add — register bundle locally
|
||||||
uncaged-workflow run — execute workflow
|
uncaged-workflow run — execute workflow
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Monorepo changes** → `bun run publish:gitea` (packages auto-discovered from `packages/*/`, topologically sorted, `workspace:*` resolved to real versions)
|
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||||
2. **Workspace** → `bun install` fetches latest from Gitea, `bun install` is safe to run anytime
|
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
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>`
|
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!**/dist",
|
||||||
|
"!**/node_modules",
|
||||||
|
"!packages/workflow/workflow",
|
||||||
|
"!xiaoju/scripts/bundle.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
|
||||||
|
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
|
||||||
|
import {
|
||||||
|
buildDevelopDescriptor,
|
||||||
|
developWorkflowDefinition,
|
||||||
|
} from "./packages/workflow-template-develop/src/index.js";
|
||||||
|
|
||||||
|
const agent = createCursorAgent({
|
||||||
|
command: "/home/azureuser/.local/bin/cursor-agent",
|
||||||
|
model: "auto",
|
||||||
|
timeout: 300_000,
|
||||||
|
workspace: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const descriptor = buildDevelopDescriptor();
|
||||||
|
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
|
||||||
+5
-6
@@ -6,18 +6,17 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bunx tsc --build",
|
"build": "bunx tsc --build",
|
||||||
"check": "bunx tsc --build && biome check .",
|
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"test": "bun run --filter '*' test",
|
"test": "bun run --filter '*' test",
|
||||||
"link": "./scripts/link-all.sh",
|
"changeset": "bunx changeset",
|
||||||
"link:consume": "./scripts/link-all.sh --consume",
|
"version": "bunx changeset version",
|
||||||
"link:unlink": "./scripts/link-all.sh --unlink",
|
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||||
"publish:gitea": "./scripts/publish-all.sh",
|
|
||||||
"publish:gitea:dry": "./scripts/publish-all.sh --dry-run"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.14",
|
"@biomejs/biome": "^2.4.14",
|
||||||
|
"@changesets/cli": "^2.31.0",
|
||||||
"@types/node": "^25.7.0",
|
"@types/node": "^25.7.0",
|
||||||
"@types/xxhashjs": "^0.2.4",
|
"@types/xxhashjs": "^0.2.4",
|
||||||
"bun-types": "^1.3.13"
|
"bun-types": "^1.3.13"
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# @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
|
||||||
+4
-4
@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
|
|||||||
|
|
||||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||||
|
|
||||||
import { createApp } from "../src/commands/serve/app.js";
|
import { createApp } from "../src/commands/connect/app.js";
|
||||||
|
|
||||||
function casStoredForm(raw: string): string {
|
function casStoredForm(raw: string): string {
|
||||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApp(storageRoot: string) {
|
function buildApp(storageRoot: string) {
|
||||||
const app = createApp(storageRoot);
|
const app = createApp(storageRoot, null);
|
||||||
return {
|
return {
|
||||||
fetch: (path: string, init?: RequestInit) =>
|
fetch: (path: string, init?: RequestInit) =>
|
||||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||||
@@ -115,7 +115,7 @@ describe("serve error handling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("global error handler returns 500 with JSON", async () => {
|
test("global error handler returns 500 with JSON", async () => {
|
||||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||||
app.get("/test-error", () => {
|
app.get("/test-error", () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
});
|
});
|
||||||
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
|
|||||||
|
|
||||||
describe("serve security", () => {
|
describe("serve security", () => {
|
||||||
test("CORS headers present on responses", async () => {
|
test("CORS headers present on responses", async () => {
|
||||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||||
const res2 = await app.fetch(
|
const res2 = await app.fetch(
|
||||||
new Request("http://localhost/healthz", {
|
new Request("http://localhost/healthz", {
|
||||||
headers: { Origin: "http://localhost:5173" },
|
headers: { Origin: "http://localhost:5173" },
|
||||||
@@ -180,6 +180,9 @@ describe("cli thread commands", () => {
|
|||||||
}
|
}
|
||||||
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
||||||
|
|
||||||
|
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||||
|
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||||
|
|
||||||
const shown = await cmdThreadShow(storageRoot, threadId);
|
const shown = await cmdThreadShow(storageRoot, threadId);
|
||||||
expect(shown.ok).toBe(true);
|
expect(shown.ok).toBe(true);
|
||||||
if (!shown.ok) {
|
if (!shown.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/cli-workflow",
|
"name": "@uncaged/cli-workflow",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
@@ -11,17 +11,20 @@
|
|||||||
"uncaged-workflow": "src/cli.ts"
|
"uncaged-workflow": "src/cli.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-gateway": "workspace:*",
|
"@uncaged/workflow-gateway": "workspace:^",
|
||||||
"@uncaged/workflow-protocol": "workspace:*",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:*",
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
"@uncaged/workflow-cas": "workspace:*",
|
"@uncaged/workflow-cas": "workspace:^",
|
||||||
"@uncaged/workflow-execute": "workspace:*",
|
"@uncaged/workflow-execute": "workspace:^",
|
||||||
"@uncaged/workflow-register": "workspace:*",
|
"@uncaged/workflow-register": "workspace:^",
|
||||||
"@uncaged/workflow-runtime": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"hono": "^4.12.18",
|
"hono": "^4.12.18",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { printCliError, printCliLine } from "./cli-output.js";
|
|||||||
import { getCommandRegistry } from "./cli-registry.js";
|
import { getCommandRegistry } from "./cli-registry.js";
|
||||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||||
|
import { dispatchConnect } from "./commands/connect/index.js";
|
||||||
import { createInitDispatcher } from "./commands/init/index.js";
|
import { createInitDispatcher } from "./commands/init/index.js";
|
||||||
import { dispatchServe } from "./commands/serve/index.js";
|
|
||||||
import { dispatchSetup } from "./commands/setup/index.js";
|
import { dispatchSetup } from "./commands/setup/index.js";
|
||||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||||
@@ -71,7 +71,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
|||||||
skill: dispatchSkill,
|
skill: dispatchSkill,
|
||||||
run: dispatchRun,
|
run: dispatchRun,
|
||||||
live: dispatchLive,
|
live: dispatchLive,
|
||||||
serve: dispatchServe,
|
connect: dispatchConnect,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
|||||||
@@ -59,12 +59,12 @@ export function formatCliUsage(
|
|||||||
);
|
);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
lines.push("Server:");
|
lines.push("Gateway:");
|
||||||
lines.push(
|
lines.push(
|
||||||
...formatUsageCommandLines([
|
...formatUsageCommandLines([
|
||||||
{
|
{
|
||||||
prefix: "serve [--port N] [--host ADDR]",
|
prefix: "connect [--name NAME] [--gateway URL]",
|
||||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
description: "Connect to workflow gateway via WebSocket",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-5
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
|
|||||||
|
|
||||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||||
|
|
||||||
export function createApp(storageRoot: string, agentToken: string | null): Hono {
|
export function createApp(storageRoot: string, clientToken: string | null): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.onError((_err, c) => {
|
app.onError((_err, c) => {
|
||||||
@@ -37,11 +37,11 @@ export function createApp(storageRoot: string, agentToken: string | null): Hono
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Agent token auth (skip healthz) ───────────────────────────────
|
// ── Client token auth (skip healthz) ───────────────────────────────
|
||||||
if (agentToken !== null) {
|
if (clientToken !== null) {
|
||||||
app.use("/api/*", async (c, next) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
const token = c.req.header("X-Agent-Token");
|
const token = c.req.header("X-Client-Token");
|
||||||
if (token !== agentToken) {
|
if (token !== clientToken) {
|
||||||
return c.json({ error: "unauthorized" }, 401);
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
+6
-40
@@ -1,51 +1,17 @@
|
|||||||
import { printCliLine } from "../../cli-output.js";
|
import { printCliLine } from "../../cli-output.js";
|
||||||
|
|
||||||
type TunnelHandle = {
|
|
||||||
process: ReturnType<typeof Bun.spawn>;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function startTunnel(port: number): Promise<TunnelHandle | null> {
|
|
||||||
const proc = Bun.spawn(["cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
// cloudflared prints the URL to stderr
|
|
||||||
const reader = proc.stderr.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
const deadline = Date.now() + 30_000;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
||||||
if (match) {
|
|
||||||
// Release the reader so stderr keeps flowing without backpressure
|
|
||||||
reader.releaseLock();
|
|
||||||
return { process: proc, url: match[0] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.releaseLock();
|
|
||||||
proc.kill();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerWithGateway(
|
export async function registerWithGateway(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
tunnelUrl: string,
|
localUrl: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
agentToken: string,
|
clientToken: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
|
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await resp.text();
|
const body = await resp.text();
|
||||||
@@ -77,12 +43,12 @@ export async function unregisterFromGateway(
|
|||||||
export function startHeartbeat(
|
export function startHeartbeat(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
tunnelUrl: string,
|
localUrl: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
agentToken: string,
|
clientToken: string,
|
||||||
intervalMs: number,
|
intervalMs: number,
|
||||||
): ReturnType<typeof setInterval> {
|
): ReturnType<typeof setInterval> {
|
||||||
return setInterval(() => {
|
return setInterval(() => {
|
||||||
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
|
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { dispatchConnect } from "./connect.js";
|
||||||
|
export type { ConnectOptions } from "./types.js";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type ConnectOptions = {
|
||||||
|
name: string;
|
||||||
|
gatewayUrl: string;
|
||||||
|
gatewaySecret: string;
|
||||||
|
};
|
||||||
+13
-14
@@ -5,7 +5,7 @@ export type GatewayWsClientParams = {
|
|||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
name: string;
|
name: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
localPort: number;
|
appFetch: (request: Request) => Response | Promise<Response>;
|
||||||
log: LogFn;
|
log: LogFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,20 +44,19 @@ async function handleGatewayMessage(
|
|||||||
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const localUrl = `http://127.0.0.1:${String(params.localPort)}${req.path}`;
|
const localUrl = `http://localhost${req.path}`;
|
||||||
const initHeaders = new Headers();
|
const headers = new Headers(req.headers);
|
||||||
for (const [k, v] of Object.entries(req.headers)) {
|
|
||||||
initHeaders.set(k, v);
|
|
||||||
}
|
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
resp = await fetch(localUrl, {
|
resp = await params.appFetch(
|
||||||
method: req.method,
|
new Request(localUrl, {
|
||||||
headers: initHeaders,
|
method: req.method,
|
||||||
body: req.body === null ? undefined : req.body,
|
headers,
|
||||||
});
|
body: req.body === null ? undefined : req.body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
params.log("R4N7BQ3C", `local proxy fetch failed: ${String(e)}`);
|
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||||
const errBody: WsResponse = {
|
const errBody: WsResponse = {
|
||||||
id: req.id,
|
id: req.id,
|
||||||
status: 502,
|
status: 502,
|
||||||
@@ -100,7 +99,7 @@ export function startGatewayWsClient(params: GatewayWsClientParams): () => void
|
|||||||
clearReconnectTimer();
|
clearReconnectTimer();
|
||||||
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||||
attempt++;
|
attempt++;
|
||||||
params.log("6CJX2RLP", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
||||||
reconnectTimer = setTimeout(connect, delayMs);
|
reconnectTimer = setTimeout(connect, delayMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +142,7 @@ export function startGatewayWsClient(params: GatewayWsClientParams): () => void
|
|||||||
ws.addEventListener("message", (ev) => {
|
ws.addEventListener("message", (ev) => {
|
||||||
const data = ev.data;
|
const data = ev.data;
|
||||||
if (typeof data !== "string") {
|
if (typeof data !== "string") {
|
||||||
params.log("T9W2KL5H", "gateway WebSocket non-text frame ignored");
|
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
||||||
@@ -196,18 +196,13 @@ uncaged-workflow init workspace ${workspaceName}
|
|||||||
|
|
||||||
function bundleTs(): string {
|
function bundleTs(): string {
|
||||||
return [
|
return [
|
||||||
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
|
'import { mkdir, readdir, writeFile } from "node:fs/promises";',
|
||||||
'import { join } from "node:path";',
|
'import { join } from "node:path";',
|
||||||
"",
|
"",
|
||||||
'const rootDir = join(import.meta.dir, "..");',
|
'const rootDir = join(import.meta.dir, "..");',
|
||||||
'const workflowsDir = join(rootDir, "workflows");',
|
'const workflowsDir = join(rootDir, "workflows");',
|
||||||
'const distDir = join(rootDir, "dist");',
|
'const distDir = join(rootDir, "dist");',
|
||||||
"",
|
"",
|
||||||
"type JsonDeps = {",
|
|
||||||
" dependencies: Record<string, string> | null;",
|
|
||||||
" devDependencies: Record<string, string> | null;",
|
|
||||||
"};",
|
|
||||||
"",
|
|
||||||
"function isEntryFile(name: string): boolean {",
|
"function isEntryFile(name: string): boolean {",
|
||||||
' return name.endsWith("-entry.ts");',
|
' return name.endsWith("-entry.ts");',
|
||||||
"}",
|
"}",
|
||||||
@@ -216,36 +211,6 @@ function bundleTs(): string {
|
|||||||
' return name.slice(0, -".ts".length);',
|
' return name.slice(0, -".ts".length);',
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
"async function uncagedWorkflowExternals(): Promise<string[]> {",
|
|
||||||
" const names = new Set<string>();",
|
|
||||||
' const paths = [join(rootDir, "package.json"), join(workflowsDir, "package.json")];',
|
|
||||||
" for (const pkgPath of paths) {",
|
|
||||||
" let raw: string;",
|
|
||||||
" try {",
|
|
||||||
' raw = await readFile(pkgPath, "utf8");',
|
|
||||||
" } catch {",
|
|
||||||
" continue;",
|
|
||||||
" }",
|
|
||||||
" const parsed = JSON.parse(raw) as JsonDeps;",
|
|
||||||
" const blocks = [parsed.dependencies, parsed.devDependencies];",
|
|
||||||
" for (const block of blocks) {",
|
|
||||||
" if (block == null) {",
|
|
||||||
" continue;",
|
|
||||||
" }",
|
|
||||||
" for (const key of Object.keys(block)) {",
|
|
||||||
' if (key.startsWith("@uncaged/workflow")) {',
|
|
||||||
" names.add(key);",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" if (names.size === 0) {",
|
|
||||||
' names.add("@uncaged/workflow-runtime");',
|
|
||||||
' names.add("@uncaged/workflow-protocol");',
|
|
||||||
" }",
|
|
||||||
" return [...names];",
|
|
||||||
"}",
|
|
||||||
"",
|
|
||||||
"async function main(): Promise<void> {",
|
"async function main(): Promise<void> {",
|
||||||
" await mkdir(distDir, { recursive: true });",
|
" await mkdir(distDir, { recursive: true });",
|
||||||
" let files: string[];",
|
" let files: string[];",
|
||||||
@@ -261,7 +226,6 @@ function bundleTs(): string {
|
|||||||
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
||||||
" return;",
|
" return;",
|
||||||
" }",
|
" }",
|
||||||
" const external = await uncagedWorkflowExternals();",
|
|
||||||
" for (const file of entries) {",
|
" for (const file of entries) {",
|
||||||
" const stem = entryStem(file);",
|
" const stem = entryStem(file);",
|
||||||
" const entryPath = join(workflowsDir, file);",
|
" const entryPath = join(workflowsDir, file);",
|
||||||
@@ -272,7 +236,6 @@ function bundleTs(): string {
|
|||||||
' target: "node",',
|
' target: "node",',
|
||||||
" splitting: false,",
|
" splitting: false,",
|
||||||
' naming: { entry: "[name].esm.js" },',
|
' naming: { entry: "[name].esm.js" },',
|
||||||
" external,",
|
|
||||||
" });",
|
" });",
|
||||||
" if (!result.success) {",
|
" if (!result.success) {",
|
||||||
" for (const log of result.logs) {",
|
" for (const log of result.logs) {",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export { createApp } from "./app.js";
|
|
||||||
export { dispatchServe, startServer } from "./serve.js";
|
|
||||||
export type { ServeOptions } from "./types.js";
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { hostname as osHostname } from "node:os";
|
|
||||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
|
||||||
import { serve } from "bun";
|
|
||||||
|
|
||||||
import { printCliLine } from "../../cli-output.js";
|
|
||||||
import { createApp } from "./app.js";
|
|
||||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./tunnel.js";
|
|
||||||
import type { ServeOptions } 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;
|
|
||||||
|
|
||||||
export function startServer(
|
|
||||||
storageRoot: string,
|
|
||||||
options: ServeOptions,
|
|
||||||
agentToken: string | null,
|
|
||||||
): void {
|
|
||||||
const app = createApp(storageRoot, agentToken);
|
|
||||||
|
|
||||||
const server = serve({
|
|
||||||
fetch: app.fetch,
|
|
||||||
port: options.port,
|
|
||||||
hostname: options.hostname,
|
|
||||||
});
|
|
||||||
|
|
||||||
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePortValue(value: string | undefined): Result<number, string> {
|
|
||||||
if (value === undefined) {
|
|
||||||
return err("--port requires a value");
|
|
||||||
}
|
|
||||||
const parsed = Number.parseInt(value, 10);
|
|
||||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
|
||||||
return err(`invalid port: ${value}`);
|
|
||||||
}
|
|
||||||
return ok(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
|
||||||
const next = argv[i + 1];
|
|
||||||
if (next === undefined) {
|
|
||||||
return err(`${flag} requires a value`);
|
|
||||||
}
|
|
||||||
return ok(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
|
||||||
let port = 7860;
|
|
||||||
let hostname = "127.0.0.1";
|
|
||||||
let name = osHostname().split(".")[0].toLowerCase();
|
|
||||||
let noTunnel = false;
|
|
||||||
let tunnelUrl: string | null = null;
|
|
||||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
|
||||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
|
||||||
const stringFlags: Record<string, (v: string) => void> = {
|
|
||||||
"--host": (v) => {
|
|
||||||
hostname = v;
|
|
||||||
},
|
|
||||||
"--name": (v) => {
|
|
||||||
name = v;
|
|
||||||
},
|
|
||||||
"--gateway": (v) => {
|
|
||||||
gatewayUrl = v;
|
|
||||||
},
|
|
||||||
"--tunnel-url": (v) => {
|
|
||||||
tunnelUrl = v;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
|
||||||
const arg = argv[i];
|
|
||||||
if (arg === "--port" || arg === "-p") {
|
|
||||||
const portResult = parsePortValue(argv[i + 1]);
|
|
||||||
if (!portResult.ok) return portResult;
|
|
||||||
port = portResult.value;
|
|
||||||
i++;
|
|
||||||
} else if (arg === "--no-tunnel") {
|
|
||||||
noTunnel = true;
|
|
||||||
} else if (arg in stringFlags) {
|
|
||||||
const r = requireNextArg(argv, i, arg);
|
|
||||||
if (!r.ok) return r;
|
|
||||||
stringFlags[arg](r.value);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok({ port, hostname, name, noTunnel, tunnelUrl, gatewayUrl, gatewaySecret });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const parsed = parseServeArgv(argv);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
printCliLine(`error: ${parsed.error}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = parsed.value;
|
|
||||||
const agentToken = options.noTunnel ? null : randomUUID();
|
|
||||||
startServer(storageRoot, options, agentToken);
|
|
||||||
|
|
||||||
if (options.noTunnel) {
|
|
||||||
printCliLine("tunnel disabled (--no-tunnel)");
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvedTunnelUrl: string;
|
|
||||||
let stopWsClient: (() => void) | null = null;
|
|
||||||
|
|
||||||
if (options.tunnelUrl !== null) {
|
|
||||||
resolvedTunnelUrl = options.tunnelUrl;
|
|
||||||
printCliLine(`using tunnel URL: ${resolvedTunnelUrl}`);
|
|
||||||
} else {
|
|
||||||
if (options.gatewaySecret === "") {
|
|
||||||
printCliLine(
|
|
||||||
"WORKFLOW_GATEWAY_SECRET not set — cannot use WebSocket gateway connection (set env or pass --tunnel-url)",
|
|
||||||
);
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
resolvedTunnelUrl = `http://127.0.0.1:${options.port}`;
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
|
||||||
stopWsClient = startGatewayWsClient({
|
|
||||||
gatewayUrl: options.gatewayUrl,
|
|
||||||
name: options.name,
|
|
||||||
secret: options.gatewaySecret,
|
|
||||||
localPort: options.port,
|
|
||||||
log,
|
|
||||||
});
|
|
||||||
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.gatewaySecret) {
|
|
||||||
if (agentToken === null) {
|
|
||||||
printCliLine("internal error: agent token missing");
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const token = agentToken;
|
|
||||||
const registered = await registerWithGateway(
|
|
||||||
options.gatewayUrl,
|
|
||||||
options.name,
|
|
||||||
resolvedTunnelUrl,
|
|
||||||
options.gatewaySecret,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
if (registered) {
|
|
||||||
printCliLine(`registered with gateway as "${options.name}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const heartbeatTimer = startHeartbeat(
|
|
||||||
options.gatewayUrl,
|
|
||||||
options.name,
|
|
||||||
resolvedTunnelUrl,
|
|
||||||
options.gatewaySecret,
|
|
||||||
token,
|
|
||||||
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);
|
|
||||||
} else {
|
|
||||||
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type ServeOptions = {
|
|
||||||
port: number;
|
|
||||||
hostname: string;
|
|
||||||
name: string;
|
|
||||||
noTunnel: boolean;
|
|
||||||
tunnelUrl: string | null;
|
|
||||||
gatewayUrl: string;
|
|
||||||
gatewaySecret: string;
|
|
||||||
};
|
|
||||||
@@ -18,13 +18,13 @@ export async function cmdThreadRemove(
|
|||||||
return err(`thread not found: ${threadId}`);
|
return err(`thread not found: ${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolved.source === "active") {
|
// Always clear both stores: between resolve and delete the worker may finish and
|
||||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
// move the thread from threads.json into history; branching only on resolved.source
|
||||||
} else {
|
// would skip history removal and leave a dangling row.
|
||||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||||
if (!hist.ok) {
|
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||||
return hist;
|
if (!hist.ok) {
|
||||||
}
|
return hist;
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export async function cmdAdd(
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
const extracted = await extractBundleExports(resolvedPath);
|
||||||
if (!extracted.ok) {
|
if (!extracted.ok) {
|
||||||
return extracted;
|
return extracted;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,11 +86,11 @@ ${commandSections.join("\n\n")}
|
|||||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||||
|
|
||||||
### serve
|
### connect
|
||||||
|
|
||||||
| Command | Args | Description |
|
| Command | Args | Description |
|
||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
|
| \`connect\` | \`[--name NAME] [--gateway URL]\` | Connect to workflow gateway via WebSocket. \`--name\` registers with the gateway. |
|
||||||
|
|
||||||
## Typical Workflow
|
## Typical Workflow
|
||||||
|
|
||||||
@@ -301,13 +301,36 @@ function createLazyAdapter(): AdapterFn {
|
|||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
### Agent CLI paths: use env() with absolute path defaults
|
||||||
|
|
||||||
|
Every env var in a bundle must have a sensible default — bundles must run without any env vars set. Use \`env(name, fallback)\` from \`@uncaged/workflow-util\`.
|
||||||
|
|
||||||
|
Discover the correct CLI path yourself (e.g. \`which cursor-agent\`, \`which hermes\`) and hardcode it as the fallback:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
import { env } from "@uncaged/workflow-util";
|
||||||
|
|
||||||
|
// ❌ WRONG — requireEnv and optionalEnv no longer exist
|
||||||
|
const adapter = createCursorAgent({
|
||||||
|
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set it"),
|
||||||
|
...
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ CORRECT — env var is an override, fallback is the discovered absolute path
|
||||||
|
const adapter = createCursorAgent({
|
||||||
|
command: env("WORKFLOW_CURSOR_COMMAND", "/home/you/.local/bin/cursor-agent"),
|
||||||
|
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
|
||||||
|
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "300000")),
|
||||||
|
...
|
||||||
|
});
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
### Bundle import restrictions
|
### Bundle import restrictions
|
||||||
|
|
||||||
The bundle validator only allows these import specifiers:
|
The bundle validator only allows these import specifiers:
|
||||||
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
||||||
- \`@uncaged/workflow-*\` packages
|
|
||||||
|
|
||||||
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
|
All other dependencies — including \`@uncaged/workflow-*\` packages, zod, and any third-party code — must be bundled into the \`.esm.js\` file. Bundles are fully self-contained: same Node/Bun version = same behavior.
|
||||||
|
|
||||||
### No default exports
|
### No default exports
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# @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,36 +1,25 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||||
|
|
||||||
describe("validateCursorAgentConfig", () => {
|
const baseConfig = {
|
||||||
test("accepts valid config with explicit workspace", () => {
|
command: "/usr/local/bin/cursor-agent",
|
||||||
const r = validateCursorAgentConfig({
|
model: null as string | null,
|
||||||
command: "/usr/local/bin/cursor-agent",
|
timeout: 0,
|
||||||
model: null,
|
workspace: null as string | null,
|
||||||
timeout: 0,
|
};
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts valid config with null workspace and llmProvider", () => {
|
describe("validateCursorAgentConfig", () => {
|
||||||
|
test("accepts valid config", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: null,
|
|
||||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects non-absolute command", () => {
|
test("rejects non-absolute command", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
|
...baseConfig,
|
||||||
command: "cursor-agent",
|
command: "cursor-agent",
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -38,87 +27,38 @@ describe("validateCursorAgentConfig", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects empty workspace string", () => {
|
test("rejects negative timeout", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
timeout: -1,
|
||||||
timeout: 0,
|
});
|
||||||
workspace: "",
|
expect(r.ok).toBe(false);
|
||||||
llmProvider: null,
|
});
|
||||||
|
|
||||||
|
test("rejects non-absolute workspace when set", () => {
|
||||||
|
const r = validateCursorAgentConfig({
|
||||||
|
...baseConfig,
|
||||||
|
workspace: "relative/path",
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
expect(r.error).toContain("workspace");
|
expect(r.error).toContain("workspace");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects null workspace without llmProvider", () => {
|
|
||||||
const r = validateCursorAgentConfig({
|
|
||||||
command: "/usr/local/bin/cursor-agent",
|
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: null,
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.error).toContain("llmProvider");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects negative timeout", () => {
|
|
||||||
const r = validateCursorAgentConfig({
|
|
||||||
command: "/usr/local/bin/cursor-agent",
|
|
||||||
model: null,
|
|
||||||
timeout: -1,
|
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createCursorAgent", () => {
|
describe("createCursorAgent", () => {
|
||||||
test("returns an AdapterFn with explicit workspace", () => {
|
test("returns an AdapterFn", () => {
|
||||||
const agent = createCursorAgent({
|
const agent = createCursorAgent({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns an AdapterFn with null workspace and llmProvider", () => {
|
|
||||||
const agent = createCursorAgent({
|
|
||||||
command: "/usr/local/bin/cursor-agent",
|
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: null,
|
|
||||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
|
||||||
});
|
});
|
||||||
expect(typeof agent).toBe("function");
|
expect(typeof agent).toBe("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
||||||
const agent = createCursorAgent({
|
const agent = createCursorAgent({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: -1,
|
timeout: -1,
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
|
|
||||||
const agent = createCursorAgent({
|
|
||||||
command: "/usr/local/bin/cursor-agent",
|
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
workspace: null,
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
});
|
||||||
expect(typeof agent).toBe("function");
|
expect(typeof agent).toBe("function");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-cursor",
|
"name": "@uncaged/workflow-agent-cursor",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:*",
|
"@uncaged/workflow-cas": "workspace:^",
|
||||||
"@uncaged/workflow-reactor": "workspace:*",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-runtime": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:*",
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*",
|
"@uncaged/workflow-util-agent": "workspace:^",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
|
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||||
import type { LogFn } from "@uncaged/workflow-util";
|
import type { LogFn } from "@uncaged/workflow-util";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
@@ -7,10 +7,7 @@ const workspaceSchema = z.object({
|
|||||||
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
|
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const EXTRACT_SYSTEM_FN = (_toolName: string) =>
|
function buildExtractionInput(ctx: ThreadContext): string {
|
||||||
`You are a workspace-path extractor. Given a workflow agent context (task description and previous step outputs), identify the absolute filesystem path of the project workspace where code changes should be made. Call the tool with the absolute path.`;
|
|
||||||
|
|
||||||
function buildExtractionInput(ctx: AgentContext): string {
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push("## Task");
|
lines.push("## Task");
|
||||||
lines.push(ctx.start.content);
|
lines.push(ctx.start.content);
|
||||||
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
|
|||||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractWorkspacePath(
|
export async function extractWorkspacePath(
|
||||||
ctx: AgentContext,
|
ctx: ThreadContext,
|
||||||
provider: LlmProvider,
|
runtime: WorkflowRuntime,
|
||||||
logger: LogFn,
|
logger: LogFn,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const reactor = createThreadReactor<null>({
|
const input = buildExtractionInput(ctx);
|
||||||
llm: createLlmFn(provider),
|
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
|
||||||
maxRounds: 2,
|
|
||||||
staticTools: [],
|
|
||||||
structuredToolFromSchema: (schema) => {
|
|
||||||
const jsonSchema = z.toJSONSchema(schema);
|
|
||||||
return {
|
|
||||||
name: "set_workspace",
|
|
||||||
tool: {
|
|
||||||
type: "function" as const,
|
|
||||||
function: {
|
|
||||||
name: "set_workspace",
|
|
||||||
description: "Set the extracted workspace path",
|
|
||||||
parameters: jsonSchema as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
systemPromptForStructuredTool: EXTRACT_SYSTEM_FN,
|
|
||||||
toolHandler: async () => "unknown tool",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await reactor({
|
const result = await runtime.extract(workspaceSchema, contentHash);
|
||||||
thread: null,
|
const workspace = result.meta.workspace.trim();
|
||||||
input: buildExtractionInput(ctx),
|
|
||||||
schema: workspaceSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = result.value.workspace.trim();
|
|
||||||
if (!workspace.startsWith("/")) {
|
if (!workspace.startsWith("/")) {
|
||||||
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
import { createLogger, type LogFn } from "@uncaged/workflow-util";
|
||||||
import {
|
import {
|
||||||
buildThreadInput,
|
buildThreadInput,
|
||||||
createTextAdapter,
|
createAgentAdapter,
|
||||||
type SpawnCliError,
|
type SpawnCliError,
|
||||||
spawnCli,
|
spawnCli,
|
||||||
} from "@uncaged/workflow-util-agent";
|
} from "@uncaged/workflow-util-agent";
|
||||||
@@ -33,36 +33,15 @@ function resolveCursorModel(model: string | null): string {
|
|||||||
return model === null ? "auto" : model;
|
return model === null ? "auto" : model;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
|
type CursorAgentOpt = { prompt: string; workspace: string };
|
||||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
|
||||||
const modelFlag = resolveCursorModel(config.model);
|
|
||||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
|
||||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
|
||||||
|
|
||||||
return createTextAdapter(async (ctx, prompt) => {
|
|
||||||
const validated = validateCursorAgentConfig(config);
|
|
||||||
if (!validated.ok) {
|
|
||||||
throw new Error(validated.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspace: string;
|
|
||||||
|
|
||||||
if (config.workspace !== null) {
|
|
||||||
workspace = config.workspace;
|
|
||||||
} else {
|
|
||||||
if (config.llmProvider === null) {
|
|
||||||
throw new Error("cursor-agent: llmProvider is required when workspace is null");
|
|
||||||
}
|
|
||||||
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
|
|
||||||
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
|
|
||||||
if (extracted === null) {
|
|
||||||
throw new Error(
|
|
||||||
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
workspace = extracted;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function createCursorAgentFn(
|
||||||
|
config: CursorAgentConfig,
|
||||||
|
modelFlag: string,
|
||||||
|
timeoutMs: number | null,
|
||||||
|
logger: LogFn,
|
||||||
|
): AgentFn<CursorAgentOpt> {
|
||||||
|
return async (ctx, { prompt, workspace }) => {
|
||||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||||
const threadInput = await buildThreadInput(ctx);
|
const threadInput = await buildThreadInput(ctx);
|
||||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||||
@@ -86,5 +65,33 @@ export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
|||||||
throwCursorSpawnError(run.error);
|
throwCursorSpawnError(run.error);
|
||||||
}
|
}
|
||||||
return run.value;
|
return run.value;
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
|
||||||
|
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||||
|
const modelFlag = resolveCursorModel(config.model);
|
||||||
|
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||||
|
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
|
return createAgentAdapter(
|
||||||
|
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
|
||||||
|
async (ctx, prompt, runtime: WorkflowRuntime) => {
|
||||||
|
const validated = validateCursorAgentConfig(config);
|
||||||
|
if (!validated.ok) {
|
||||||
|
throw new Error(validated.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace =
|
||||||
|
config.workspace !== null
|
||||||
|
? config.workspace
|
||||||
|
: await extractWorkspacePath(ctx, runtime, logger);
|
||||||
|
if (workspace === null) {
|
||||||
|
throw new Error(
|
||||||
|
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { prompt, workspace };
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
|
||||||
|
|
||||||
export type CursorAgentConfig = {
|
export type CursorAgentConfig = {
|
||||||
/** Absolute path to the cursor-agent CLI binary. */
|
/** Absolute path to the cursor-agent CLI binary. */
|
||||||
command: string;
|
command: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
|
/**
|
||||||
|
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
|
||||||
|
* from the thread via runtime extraction.
|
||||||
|
*/
|
||||||
workspace: string | null;
|
workspace: string | null;
|
||||||
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
|
|
||||||
llmProvider: LlmProvider | null;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,14 +8,11 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
|
|||||||
if (!isAbsolute(config.command)) {
|
if (!isAbsolute(config.command)) {
|
||||||
return err("command must be an absolute path to the cursor-agent CLI binary");
|
return err("command must be an absolute path to the cursor-agent CLI binary");
|
||||||
}
|
}
|
||||||
if (config.workspace !== null && config.workspace.length === 0) {
|
|
||||||
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
|
|
||||||
}
|
|
||||||
if (config.workspace === null && config.llmProvider === null) {
|
|
||||||
return err("llmProvider is required when workspace is null (needed for workspace extraction)");
|
|
||||||
}
|
|
||||||
if (config.timeout < 0) {
|
if (config.timeout < 0) {
|
||||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||||
}
|
}
|
||||||
|
if (config.workspace !== null && !isAbsolute(config.workspace)) {
|
||||||
|
return err("workspace must be an absolute filesystem path when set");
|
||||||
|
}
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
"references": [
|
||||||
|
{ "path": "../workflow-cas" },
|
||||||
|
{ "path": "../workflow-runtime" },
|
||||||
|
{ "path": "../workflow-util-agent" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# @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,24 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-hermes",
|
"name": "@uncaged/workflow-agent-hermes",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-runtime": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*"
|
"@uncaged/workflow-util-agent": "workspace:^"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
|
||||||
import {
|
import {
|
||||||
buildThreadInput,
|
buildThreadInput,
|
||||||
createTextAdapter,
|
createAgentAdapter,
|
||||||
type SpawnCliError,
|
type SpawnCliError,
|
||||||
spawnCli,
|
spawnCli,
|
||||||
} from "@uncaged/workflow-util-agent";
|
} from "@uncaged/workflow-util-agent";
|
||||||
@@ -11,6 +11,8 @@ import { validateHermesAgentConfig } from "./validate-config.js";
|
|||||||
|
|
||||||
const HERMES_DEFAULT_MAX_TURNS = 90;
|
const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||||
|
|
||||||
|
type HermesAgentOpt = { prompt: string };
|
||||||
|
|
||||||
export type { HermesAgentConfig } from "./types.js";
|
export type { HermesAgentConfig } from "./types.js";
|
||||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||||
|
|
||||||
@@ -29,16 +31,10 @@ function throwHermesSpawnError(error: SpawnCliError): never {
|
|||||||
throw new Error("hermes: unknown spawn error");
|
throw new Error("hermes: unknown spawn error");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
|
||||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
|
||||||
const timeoutMs = config.timeout;
|
const timeoutMs = config.timeout;
|
||||||
|
|
||||||
return createTextAdapter(async (ctx, prompt) => {
|
return async (ctx, { prompt }) => {
|
||||||
const validated = validateHermesAgentConfig(config);
|
|
||||||
if (!validated.ok) {
|
|
||||||
throw new Error(validated.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const threadInput = await buildThreadInput(ctx);
|
const threadInput = await buildThreadInput(ctx);
|
||||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||||
const args = [
|
const args = [
|
||||||
@@ -61,5 +57,16 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
|||||||
throwHermesSpawnError(run.error);
|
throwHermesSpawnError(run.error);
|
||||||
}
|
}
|
||||||
return run.value;
|
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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# @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 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-llm",
|
"name": "@uncaged/workflow-agent-llm",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-runtime": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*"
|
"@uncaged/workflow-util-agent": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
|
import {
|
||||||
import { createTextAdapter } from "@uncaged/workflow-util-agent";
|
type AdapterFn,
|
||||||
|
type AgentFn,
|
||||||
|
err,
|
||||||
|
type LlmProvider,
|
||||||
|
ok,
|
||||||
|
type Result,
|
||||||
|
} from "@uncaged/workflow-runtime";
|
||||||
|
import { createAgentAdapter } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||||
@@ -91,9 +98,10 @@ export async function chatCompletionText(options: {
|
|||||||
return parseAssistantText(res.value);
|
return parseAssistantText(res.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
type LlmAgentOpt = { prompt: string };
|
||||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
|
||||||
return createTextAdapter(async (ctx, prompt) => {
|
function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
|
||||||
|
return async (ctx, { prompt }) => {
|
||||||
const result = await chatCompletionText({
|
const result = await chatCompletionText({
|
||||||
provider,
|
provider,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -105,5 +113,12 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
|||||||
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
||||||
}
|
}
|
||||||
return result.value;
|
return result.value;
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||||
|
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||||
|
return createAgentAdapter(createLlmAgent(provider), async (_ctx, prompt, _runtime) => ({
|
||||||
|
prompt,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# @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,31 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-react",
|
"name": "@uncaged/workflow-agent-react",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./src/index.ts",
|
"bun": "./src/index.ts",
|
||||||
"default": "./src/index.ts"
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:*",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-reactor": "workspace:*",
|
"@uncaged/workflow-reactor": "workspace:^",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*"
|
"@uncaged/workflow-util-agent": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# @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,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-cas",
|
"name": "@uncaged/workflow-cas",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
@@ -11,17 +12,21 @@
|
|||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:*",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:*",
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
"xxhashjs": "^0.2.2",
|
"xxhashjs": "^0.2.2",
|
||||||
"yaml": "^2.7.1"
|
"yaml": "^2.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
|
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
|
||||||
|
|||||||
@@ -13,9 +13,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dagrejs/dagre": "^3.0.0",
|
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"elkjs": "^0.11.1",
|
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ function authHeaders(): Record<string, string> {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentBase(agent: string): string {
|
function clientBase(client: string): string {
|
||||||
if (GATEWAY_URL) {
|
if (GATEWAY_URL) {
|
||||||
return `${GATEWAY_URL}/api/agents/${agent}`;
|
return `${GATEWAY_URL}/api/clients/${client}`;
|
||||||
}
|
}
|
||||||
// Local dev: proxy via vite, no agent prefix
|
// Local dev: proxy via vite, no client prefix
|
||||||
return "/api";
|
return "/api";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ async function fetchJson<T>(base: string, path: string): Promise<T> {
|
|||||||
|
|
||||||
// ── Endpoint types ──────────────────────────────────────────────────
|
// ── Endpoint types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export type AgentEndpoint = {
|
export type ClientEndpoint = {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -122,6 +122,7 @@ export type WorkflowGraph = {
|
|||||||
|
|
||||||
export type WorkflowRoleDescriptor = {
|
export type WorkflowRoleDescriptor = {
|
||||||
description: string;
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
schema: Record<string, unknown>;
|
schema: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,61 +142,61 @@ export type WorkflowDetail = {
|
|||||||
|
|
||||||
// ── Gateway endpoints ───────────────────────────────────────────────
|
// ── Gateway endpoints ───────────────────────────────────────────────
|
||||||
|
|
||||||
export function listAgents(): Promise<AgentEndpoint[]> {
|
export function listClients(): Promise<ClientEndpoint[]> {
|
||||||
const url = GATEWAY_URL || "";
|
const url = GATEWAY_URL || "";
|
||||||
return fetchJson(url, "/api/gateway/endpoints");
|
return fetchJson(url, "/api/gateway/endpoints");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agent-scoped endpoints ──────────────────────────────────────────
|
// ── Client-scoped endpoints ──────────────────────────────────────────
|
||||||
|
|
||||||
export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSummary[] }> {
|
export function listWorkflows(client: string): Promise<{ workflows: WorkflowSummary[] }> {
|
||||||
return fetchJson(agentBase(agent), "/workflows");
|
return fetchJson(clientBase(client), "/workflows");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkflowDetail(agent: string, name: string): Promise<WorkflowDetail> {
|
export async function getWorkflowDetail(client: string, name: string): Promise<WorkflowDetail> {
|
||||||
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`);
|
return fetchJson<WorkflowDetail>(clientBase(client), `/workflows/${encodeURIComponent(name)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkflowDescriptor(
|
export async function getWorkflowDescriptor(
|
||||||
agent: string,
|
client: string,
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<WorkflowDescriptor | null> {
|
): Promise<WorkflowDescriptor | null> {
|
||||||
const res = await getWorkflowDetail(agent, name);
|
const res = await getWorkflowDetail(client, name);
|
||||||
return res.descriptor;
|
return res.descriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
export function listThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
|
||||||
return fetchJson(agentBase(agent), "/threads");
|
return fetchJson(clientBase(client), "/threads");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRunningThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
export function listRunningThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
|
||||||
return fetchJson(agentBase(agent), "/threads/running");
|
return fetchJson(clientBase(client), "/threads/running");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getThread(agent: string, id: string): Promise<{ records: ThreadRecord[] }> {
|
export function getThread(client: string, id: string): Promise<{ records: ThreadRecord[] }> {
|
||||||
return fetchJson(agentBase(agent), `/threads/${id}`);
|
return fetchJson(clientBase(client), `/threads/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runThread(
|
export function runThread(
|
||||||
agent: string,
|
client: string,
|
||||||
workflow: string,
|
workflow: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<{ threadId: string }> {
|
): Promise<{ threadId: string }> {
|
||||||
return postJson(agentBase(agent), "/threads", { workflow, prompt });
|
return postJson(clientBase(client), "/threads", { workflow, prompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
export function killThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||||
return postJson(agentBase(agent), `/threads/${threadId}/kill`, {});
|
return postJson(clientBase(client), `/threads/${threadId}/kill`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pauseThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
export function pauseThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||||
return postJson(agentBase(agent), `/threads/${threadId}/pause`, {});
|
return postJson(clientBase(client), `/threads/${threadId}/pause`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resumeThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
export function resumeThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||||
return postJson(agentBase(agent), `/threads/${threadId}/resume`, {});
|
return postJson(clientBase(client), `/threads/${threadId}/resume`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAgentHealth(agent: string): Promise<{ ok: boolean }> {
|
export function getClientHealth(client: string): Promise<{ ok: boolean }> {
|
||||||
return fetchJson(agentBase(agent), "/healthz");
|
return fetchJson(clientBase(client), "/healthz");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { Sidebar } from "./components/sidebar.tsx";
|
|||||||
import { StatusBar } from "./components/status-bar.tsx";
|
import { StatusBar } from "./components/status-bar.tsx";
|
||||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||||
import { ThreadList } from "./components/thread-list.tsx";
|
import { ThreadList } from "./components/thread-list.tsx";
|
||||||
|
import { WorkflowDetail } from "./components/workflow-detail.tsx";
|
||||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||||
import { useHashRoute } from "./use-hash-route.ts";
|
import { useHashRoute } from "./use-hash-route.ts";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [authed, setAuthed] = useState(hasApiKey());
|
const [authed, setAuthed] = useState(hasApiKey());
|
||||||
const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute();
|
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
|
||||||
|
useHashRoute();
|
||||||
const [showRun, setShowRun] = useState(false);
|
const [showRun, setShowRun] = useState(false);
|
||||||
|
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
@@ -22,36 +24,45 @@ export function App() {
|
|||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
view={view}
|
view={view}
|
||||||
agent={agent}
|
client={client}
|
||||||
onViewChange={setView}
|
onViewChange={setView}
|
||||||
onAgentChange={setAgent}
|
onClientChange={setClient}
|
||||||
onLogout={() => {
|
onLogout={() => {
|
||||||
clearApiKey();
|
clearApiKey();
|
||||||
setAuthed(false);
|
setAuthed(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-hidden flex flex-col">
|
<main className="flex-1 overflow-hidden flex flex-col">
|
||||||
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
|
<StatusBar client={client} onRun={() => setShowRun(true)} />
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
{!agent && (
|
{!client && (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p style={{ color: "var(--color-text-muted)" }}>
|
<p style={{ color: "var(--color-text-muted)" }}>
|
||||||
Select an agent from the sidebar to get started.
|
Select an client from the sidebar to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{agent && view === "threads" && threadId === null && (
|
{client && view === "threads" && threadId === null && (
|
||||||
<ThreadList agent={agent} onSelect={setThreadId} />
|
<ThreadList client={client} onSelect={setThreadId} />
|
||||||
)}
|
)}
|
||||||
{agent && view === "threads" && threadId !== null && (
|
{client && view === "threads" && threadId !== null && (
|
||||||
<ThreadDetail agent={agent} threadId={threadId} onBack={() => setThreadId(null)} />
|
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||||
|
)}
|
||||||
|
{client && view === "workflows" && workflowName === null && (
|
||||||
|
<WorkflowList client={client} onSelect={setWorkflowName} />
|
||||||
|
)}
|
||||||
|
{client && view === "workflows" && workflowName !== null && (
|
||||||
|
<WorkflowDetail
|
||||||
|
client={client}
|
||||||
|
workflowName={workflowName}
|
||||||
|
onBack={() => setWorkflowName(null)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{agent && view === "workflows" && <WorkflowList agent={agent} />}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{showRun && agent && (
|
{showRun && client && (
|
||||||
<RunDialog
|
<RunDialog
|
||||||
agent={agent}
|
client={client}
|
||||||
onClose={() => setShowRun(false)}
|
onClose={() => setShowRun(false)}
|
||||||
onCreated={(id) => {
|
onCreated={(id) => {
|
||||||
setShowRun(false);
|
setShowRun(false);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Markdown } from "./markdown.tsx";
|
|||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
preparer: "#8b5cf6",
|
preparer: "#8b5cf6",
|
||||||
agent: "#3b82f6",
|
client: "#3b82f6",
|
||||||
extractor: "#f59e0b",
|
extractor: "#f59e0b",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { listWorkflows, runThread } from "../api.ts";
|
|||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string;
|
client: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: (threadId: string) => void;
|
onCreated: (threadId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RunDialog({ agent, onClose, onCreated }: Props) {
|
export function RunDialog({ client, onClose, onCreated }: Props) {
|
||||||
const workflows = useFetch(() => listWorkflows(agent), [agent]);
|
const workflows = useFetch(() => listWorkflows(client), [client]);
|
||||||
const [workflow, setWorkflow] = useState("");
|
const [workflow, setWorkflow] = useState("");
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -21,7 +21,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await runThread(agent, workflow, prompt);
|
const result = await runThread(client, workflow, prompt);
|
||||||
onCreated(result.threadId);
|
onCreated(result.threadId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -38,7 +38,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
|||||||
className="w-full max-w-lg p-6 rounded-lg border"
|
className="w-full max-w-lg p-6 rounded-lg border"
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold mb-4">Run Thread on {agent}</h3>
|
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import type { AgentEndpoint } from "../api.ts";
|
import type { ClientEndpoint } from "../api.ts";
|
||||||
import { listAgents } from "../api.ts";
|
import { listClients } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
view: "threads" | "workflows";
|
view: "threads" | "workflows";
|
||||||
agent: string | null;
|
client: string | null;
|
||||||
onViewChange: (v: "threads" | "workflows") => void;
|
onViewChange: (v: "threads" | "workflows") => void;
|
||||||
onAgentChange: (a: string | null) => void;
|
onClientChange: (a: string | null) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) {
|
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
|
||||||
const { status, data } = useFetch(() => listAgents(), []);
|
const { status, data } = useFetch(() => listClients(), []);
|
||||||
|
|
||||||
const agents: AgentEndpoint[] = status === "ok" ? data : [];
|
const clients: ClientEndpoint[] = status === "ok" ? data : [];
|
||||||
|
|
||||||
// Auto-select first agent when none is selected
|
// Auto-select first client when none is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (agent === null && agents.length > 0) {
|
if (client === null && clients.length > 0) {
|
||||||
onAgentChange(agents[0].name);
|
onClientChange(clients[0].name);
|
||||||
}
|
}
|
||||||
}, [agent, agents, onAgentChange]);
|
}, [client, clients, onClientChange]);
|
||||||
|
|
||||||
const viewItems = [
|
const viewItems = [
|
||||||
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
||||||
@@ -42,33 +42,33 @@ export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }:
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent selector */}
|
{/* Client selector */}
|
||||||
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||||
<label
|
<label
|
||||||
className="block text-xs font-medium mb-1"
|
className="block text-xs font-medium mb-1"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
htmlFor="agent-select"
|
htmlFor="client-select"
|
||||||
>
|
>
|
||||||
Agent
|
Client
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="agent-select"
|
id="client-select"
|
||||||
className="w-full rounded px-2 py-1.5 text-xs"
|
className="w-full rounded px-2 py-1.5 text-xs"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--color-bg)",
|
background: "var(--color-bg)",
|
||||||
color: "var(--color-text)",
|
color: "var(--color-text)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
}}
|
}}
|
||||||
value={agent ?? ""}
|
value={client ?? ""}
|
||||||
onChange={(e) => onAgentChange(e.target.value || null)}
|
onChange={(e) => onClientChange(e.target.value || null)}
|
||||||
disabled={status === "loading"}
|
disabled={status === "loading"}
|
||||||
>
|
>
|
||||||
{status === "loading" ? (
|
{status === "loading" ? (
|
||||||
<option value="">Loading…</option>
|
<option value="">Loading…</option>
|
||||||
) : agents.length === 0 ? (
|
) : clients.length === 0 ? (
|
||||||
<option value="">No agents online</option>
|
<option value="">No clients online</option>
|
||||||
) : (
|
) : (
|
||||||
agents.map((a) => (
|
clients.map((a) => (
|
||||||
<option key={a.name} value={a.name}>
|
<option key={a.name} value={a.name}>
|
||||||
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getAgentHealth } from "../api.ts";
|
import { getClientHealth } from "../api.ts";
|
||||||
|
|
||||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string | null;
|
client: string | null;
|
||||||
onRun: () => void;
|
onRun: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,17 +18,17 @@ function statusLabel(status: HealthStatus): { text: string; color: string } {
|
|||||||
return { text: "● Offline", color: "var(--color-error)" };
|
return { text: "● Offline", color: "var(--color-error)" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBar({ agent, onRun }: Props) {
|
export function StatusBar({ client, onRun }: Props) {
|
||||||
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
||||||
const wasConnectedRef = useRef(false);
|
const wasConnectedRef = useRef(false);
|
||||||
|
|
||||||
const checkHealth = useCallback(async () => {
|
const checkHealth = useCallback(async () => {
|
||||||
if (!agent) {
|
if (!client) {
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await getAgentHealth(agent);
|
await getClientHealth(client);
|
||||||
wasConnectedRef.current = true;
|
wasConnectedRef.current = true;
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -38,7 +38,7 @@ export function StatusBar({ agent, onRun }: Props) {
|
|||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [agent]);
|
}, [client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
wasConnectedRef.current = false;
|
wasConnectedRef.current = false;
|
||||||
@@ -57,17 +57,17 @@ export function StatusBar({ agent, onRun }: Props) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span style={{ color: "var(--color-text-muted)" }}>
|
<span style={{ color: "var(--color-text-muted)" }}>
|
||||||
{agent ? `Agent: ${agent}` : "No agent selected"}
|
{client ? `Client: ${client}` : "No client selected"}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRun}
|
onClick={onRun}
|
||||||
disabled={!agent}
|
disabled={!client}
|
||||||
className="px-3 py-1 rounded text-xs font-medium"
|
className="px-3 py-1 rounded text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
background: agent ? "var(--color-accent)" : "var(--color-border)",
|
background: client ? "var(--color-accent)" : "var(--color-border)",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
opacity: agent ? 1 : 0.5,
|
opacity: client ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
▶ Run Thread
|
▶ Run Thread
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { RecordCard } from "./record-card.tsx";
|
|||||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string;
|
client: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
};
|
};
|
||||||
@@ -39,7 +39,8 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
|||||||
states.set(role, !hasResult && isLast ? "active" : "completed");
|
states.set(role, !hasResult && isLast ? "active" : "completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roleRecords.length > 0) {
|
const hasStart = records.some((r) => r.type === "thread-start");
|
||||||
|
if (hasStart) {
|
||||||
states.set("__start__", "completed");
|
states.set("__start__", "completed");
|
||||||
}
|
}
|
||||||
if (hasResult) {
|
if (hasResult) {
|
||||||
@@ -52,9 +53,9 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
|||||||
return states;
|
return states;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||||
const sse = useSSE(agent, threadId);
|
const sse = useSSE(client, threadId);
|
||||||
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
|
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
|
||||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||||
const recordsEndRef = useRef<HTMLDivElement>(null);
|
const recordsEndRef = useRef<HTMLDivElement>(null);
|
||||||
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
@@ -72,35 +73,68 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
|||||||
|
|
||||||
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
|
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
|
||||||
() =>
|
() =>
|
||||||
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(agent, workflowName),
|
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(client, workflowName),
|
||||||
[agent, workflowName],
|
[client, workflowName],
|
||||||
);
|
);
|
||||||
|
|
||||||
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
||||||
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
|
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
|
||||||
|
|
||||||
const firstIndexByRole = useMemo(() => {
|
const indicesByRole = useMemo(() => {
|
||||||
const m = new Map<string, number>();
|
const m = new Map<string, number[]>();
|
||||||
for (let i = 0; i < records.length; i++) {
|
for (let i = 0; i < records.length; i++) {
|
||||||
const r = records[i];
|
const r = records[i];
|
||||||
if (r.type === "role" && !m.has(r.role)) {
|
if (r.type === "role") {
|
||||||
m.set(r.role, i);
|
const list = m.get(r.role) ?? [];
|
||||||
|
list.push(i);
|
||||||
|
m.set(r.role, list);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
}, [records]);
|
}, [records]);
|
||||||
|
|
||||||
const handleGraphNodeClick = useCallback((roleName: string) => {
|
// Track which occurrence to jump to next per role (cycling)
|
||||||
const el = firstCardByRoleRef.current.get(roleName);
|
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
||||||
if (el == null) return;
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
const handleGraphNodeClick = useCallback(
|
||||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
(nodeId: string) => {
|
||||||
setHighlightedRole(roleName);
|
// Only allow clicks on lit (non-default) nodes
|
||||||
highlightTimerRef.current = setTimeout(() => {
|
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
||||||
setHighlightedRole(null);
|
|
||||||
highlightTimerRef.current = null;
|
// __start__: scroll to the first record (thread-start prompt)
|
||||||
}, 1500);
|
if (nodeId === "__start__") {
|
||||||
}, []);
|
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||||
|
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// __end__: scroll to bottom
|
||||||
|
if (nodeId === "__end__") {
|
||||||
|
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role nodes: cycle through occurrences
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -117,7 +151,7 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
|||||||
setActionStatus(`${action}ing...`);
|
setActionStatus(`${action}ing...`);
|
||||||
try {
|
try {
|
||||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||||
await fn(agent, threadId);
|
await fn(client, threadId);
|
||||||
setActionStatus(`${action} sent ✓`);
|
setActionStatus(`${action} sent ✓`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
@@ -237,11 +271,13 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
|||||||
{records.map((r, i) => {
|
{records.map((r, i) => {
|
||||||
const key = `${threadId}-${i}`;
|
const key = `${threadId}-${i}`;
|
||||||
if (r.type === "role") {
|
if (r.type === "role") {
|
||||||
const isFirstForRole = firstIndexByRole.get(r.role) === i;
|
const roleIndices = indicesByRole.get(r.role);
|
||||||
|
const isFirstForRole = roleIndices !== undefined && roleIndices[0] === i;
|
||||||
const flash = highlightedRole === r.role;
|
const flash = highlightedRole === r.role;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
|
data-record-index={i}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (!isFirstForRole) return;
|
if (!isFirstForRole) return;
|
||||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||||
@@ -252,7 +288,11 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <RecordCard key={key} record={r} highlighted={false} />;
|
return (
|
||||||
|
<div key={key} data-record-index={i}>
|
||||||
|
<RecordCard record={r} highlighted={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
<div ref={recordsEndRef} aria-hidden />
|
<div ref={recordsEndRef} aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { listThreads } from "../api.ts";
|
|||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string;
|
client: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ThreadList({ agent, onSelect }: Props) {
|
export function ThreadList({ client, onSelect }: Props) {
|
||||||
const { status, data, error } = useFetch(() => listThreads(agent), [agent]);
|
const { status, data, error } = useFetch(() => listThreads(client), [client]);
|
||||||
|
|
||||||
if (status === "loading")
|
if (status === "loading")
|
||||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
|
||||||
|
import { getWorkflowDetail } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
import { Markdown } from "./markdown.tsx";
|
||||||
|
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
client: string;
|
||||||
|
workflowName: string;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function versionCount(detail: WorkflowDetailData): number {
|
||||||
|
return detail.history.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema rendering helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
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[] = [];
|
||||||
|
|
||||||
|
// Handle oneOf / discriminatedUnion
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Components ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
|
||||||
|
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`role-${roleName}`}
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<h4 className="text-sm font-semibold font-mono mb-1" style={{ color: "var(--color-text)" }}>
|
||||||
|
{roleName}
|
||||||
|
</h4>
|
||||||
|
{role.description !== "" && (
|
||||||
|
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{role.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{role.systemPrompt !== "" && (
|
||||||
|
<details className="mb-3">
|
||||||
|
<summary
|
||||||
|
className="text-[10px] uppercase tracking-wider font-medium cursor-pointer select-none"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
System Prompt
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
className="mt-1 p-2 rounded overflow-y-auto text-xs"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
maxHeight: "300px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Markdown content={role.systemPrompt} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Meta Schema
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
|
||||||
|
<th
|
||||||
|
className="text-left py-1 pr-3 font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Field
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left py-1 pr-3 font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left py-1 font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.key}
|
||||||
|
style={{
|
||||||
|
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="py-1 pr-3 font-mono whitespace-pre"
|
||||||
|
style={{
|
||||||
|
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
|
||||||
|
fontStyle: r.isVariantHeader ? "italic" : "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{r.type}
|
||||||
|
</td>
|
||||||
|
<td className="py-1" style={{ color: "var(--color-text)" }}>
|
||||||
|
{r.description || (r.isVariantHeader ? "" : "—")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
||||||
|
<pre
|
||||||
|
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
||||||
|
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{JSON.stringify(role.schema, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function WorkflowDetail({ client, workflowName, onBack }: Props) {
|
||||||
|
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
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
← Back to workflows
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold mb-4 font-mono">{workflowName}</h2>
|
||||||
|
|
||||||
|
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
|
||||||
|
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
|
||||||
|
|
||||||
|
{detail !== null && (
|
||||||
|
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
|
||||||
|
{/* Left: fixed graph sidebar */}
|
||||||
|
{hasGraph && (
|
||||||
|
<div
|
||||||
|
className="shrink-0"
|
||||||
|
style={{
|
||||||
|
width: 280,
|
||||||
|
position: "sticky",
|
||||||
|
top: 16,
|
||||||
|
height: "calc(100vh - 160px)",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<span className="font-mono">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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: scrollable content */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-4">
|
||||||
|
{/* Workflow overview */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm whitespace-pre-wrap mb-3"
|
||||||
|
style={{ color: "var(--color-text)" }}
|
||||||
|
>
|
||||||
|
{descriptor !== null && descriptor.description !== ""
|
||||||
|
? descriptor.description
|
||||||
|
: "—"}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
<span>
|
||||||
|
Hash:{" "}
|
||||||
|
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
|
||||||
|
{detail.hash}
|
||||||
|
</code>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{roleEntries.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role cards */}
|
||||||
|
{roleEntries.map(([name, role]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
style={{
|
||||||
|
transition: "box-shadow 0.3s",
|
||||||
|
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoleCard roleName={name} role={role} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,48 @@
|
|||||||
import {
|
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
|
||||||
BaseEdge,
|
|
||||||
EdgeLabelRenderer,
|
|
||||||
type EdgeProps,
|
|
||||||
getSmoothStepPath,
|
|
||||||
} from "@xyflow/react";
|
|
||||||
import type { ConditionEdgeData } from "./types.ts";
|
import type { ConditionEdgeData } from "./types.ts";
|
||||||
|
|
||||||
|
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
|
||||||
|
const FEEDBACK_OFFSET_X = 80;
|
||||||
|
// Radius for feedback edge corners
|
||||||
|
const FEEDBACK_RADIUS = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an SVG path for an edge routed to the side of the nodes.
|
||||||
|
* Works for both feedback (bottom→up) and skip-forward (top→down) edges.
|
||||||
|
* The path goes: source → horizontal to side → vertical → horizontal to target
|
||||||
|
*/
|
||||||
|
function sidePath(
|
||||||
|
sourceX: number,
|
||||||
|
sourceY: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
side: "right" | "left",
|
||||||
|
): string {
|
||||||
|
const d = side === "right" ? 1 : -1;
|
||||||
|
const offsetX =
|
||||||
|
side === "right"
|
||||||
|
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
|
||||||
|
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
|
||||||
|
const r = FEEDBACK_RADIUS;
|
||||||
|
|
||||||
|
// Direction: going up (feedback) or down (skip-forward)
|
||||||
|
const goingDown = targetY > sourceY;
|
||||||
|
const vertSourceY = goingDown ? sourceY + r : sourceY - r;
|
||||||
|
const vertTargetY = goingDown ? targetY - r : targetY + r;
|
||||||
|
|
||||||
|
const segments = [
|
||||||
|
`M ${sourceX} ${sourceY}`,
|
||||||
|
`L ${offsetX - d * r} ${sourceY}`,
|
||||||
|
`Q ${offsetX} ${sourceY} ${offsetX} ${vertSourceY}`,
|
||||||
|
`L ${offsetX} ${vertTargetY}`,
|
||||||
|
`Q ${offsetX} ${targetY} ${offsetX - d * r} ${targetY}`,
|
||||||
|
`L ${targetX} ${targetY}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return segments.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: edge routing logic is inherently branchy
|
||||||
export function ConditionEdge(props: EdgeProps) {
|
export function ConditionEdge(props: EdgeProps) {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -23,34 +60,47 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
const edgeData = data as ConditionEdgeData | undefined;
|
const edgeData = data as ConditionEdgeData | undefined;
|
||||||
const isFallback = edgeData?.isFallback ?? false;
|
const isFallback = edgeData?.isFallback ?? false;
|
||||||
const isSelfLoop = source === target;
|
const isSelfLoop = source === target;
|
||||||
|
const isFeedback = edgeData?.isFeedback ?? false;
|
||||||
|
|
||||||
const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({
|
let path: string;
|
||||||
sourceX,
|
let defaultLabelX: number;
|
||||||
sourceY,
|
let defaultLabelY: number;
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
borderRadius: isSelfLoop ? 20 : 8,
|
|
||||||
offset: isSelfLoop ? 50 : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
|
if (isFeedback) {
|
||||||
const strokeDasharray = isFallback ? "5 4" : undefined;
|
const side = edgeData?.feedbackSide ?? "right";
|
||||||
const label = edgeData?.condition ?? "";
|
path = sidePath(sourceX, sourceY, targetX, targetY, side);
|
||||||
|
const offsetX =
|
||||||
|
side === "right"
|
||||||
|
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
|
||||||
|
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
|
||||||
|
defaultLabelX = offsetX;
|
||||||
|
defaultLabelY = (sourceY + targetY) / 2;
|
||||||
|
} else {
|
||||||
|
const result = getSmoothStepPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
borderRadius: isSelfLoop ? 20 : 8,
|
||||||
|
offset: isSelfLoop ? 50 : undefined,
|
||||||
|
});
|
||||||
|
path = result[0];
|
||||||
|
defaultLabelX = result[1];
|
||||||
|
defaultLabelY = result[2];
|
||||||
|
}
|
||||||
|
|
||||||
// Use ELK-computed label position if available, otherwise fall back to ReactFlow default
|
const stroke = "var(--color-accent)";
|
||||||
const labelX = edgeData?.elkLabelX ?? defaultLabelX;
|
const label = isFallback ? "" : (edgeData?.condition ?? "");
|
||||||
const labelY = edgeData?.elkLabelY ?? defaultLabelY;
|
|
||||||
|
// Use pre-computed label position if available, otherwise fall back to default
|
||||||
|
const labelX = edgeData?.labelX ?? defaultLabelX;
|
||||||
|
const labelY = edgeData?.labelY ?? defaultLabelY;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge
|
<BaseEdge id={id} path={path} markerEnd={markerEnd} style={{ stroke, strokeWidth: 1.5 }} />
|
||||||
id={id}
|
|
||||||
path={path}
|
|
||||||
markerEnd={markerEnd}
|
|
||||||
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
|
|
||||||
/>
|
|
||||||
{label !== "" && (
|
{label !== "" && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
@@ -59,7 +109,7 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
color: isFallback ? "var(--color-text-muted)" : "var(--color-text)",
|
color: "var(--color-text)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function RoleNode(props: NodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium cursor-pointer ${isActive ? "wf-node-pulse" : ""}`}
|
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${data.state !== "default" ? "cursor-pointer" : ""} ${isActive ? "wf-node-pulse" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
width: 180,
|
width: 180,
|
||||||
height: 60,
|
height: 60,
|
||||||
@@ -45,7 +45,41 @@ export function RoleNode(props: NodeProps) {
|
|||||||
}}
|
}}
|
||||||
title={data.description}
|
title={data.description}
|
||||||
>
|
>
|
||||||
<Handle type="target" position={Position.Top} 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}
|
||||||
|
/>
|
||||||
|
<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">
|
<div className="flex items-center gap-1.5 font-mono">
|
||||||
{icon !== null && (
|
{icon !== null && (
|
||||||
<span
|
<span
|
||||||
@@ -63,7 +97,13 @@ export function RoleNode(props: NodeProps) {
|
|||||||
{data.description}
|
{data.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="bottom-out"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function TerminalNode(props: NodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""}`}
|
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -45,11 +45,34 @@ export function TerminalNode(props: NodeProps) {
|
|||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
|
id="bottom-out"
|
||||||
style={handleStyle}
|
style={handleStyle}
|
||||||
isConnectable={false}
|
isConnectable={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Handle type="target" position={Position.Top} 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 ? "▶" : "■"}
|
{isStart ? "▶" : "■"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ export type ConditionEdgeData = {
|
|||||||
condition: string;
|
condition: string;
|
||||||
conditionDescription: string | null;
|
conditionDescription: string | null;
|
||||||
isFallback: boolean;
|
isFallback: boolean;
|
||||||
elkLabelX: number | null;
|
isFeedback: boolean;
|
||||||
elkLabelY: number | null;
|
isSelfLoop: boolean;
|
||||||
|
feedbackSide: "right" | "left" | null;
|
||||||
|
labelX: number | null;
|
||||||
|
labelY: number | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Edge, Node } from "@xyflow/react";
|
import type { Edge, Node } from "@xyflow/react";
|
||||||
import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js";
|
import { useMemo } from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import type { WorkflowGraphEdge } from "../../api.ts";
|
import type { WorkflowGraphEdge } from "../../api.ts";
|
||||||
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
import type { NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
||||||
|
|
||||||
const START_ID = "__start__";
|
const START_ID = "__start__";
|
||||||
const END_ID = "__end__";
|
const END_ID = "__end__";
|
||||||
@@ -10,6 +9,11 @@ const ROLE_NODE_WIDTH = 180;
|
|||||||
const ROLE_NODE_HEIGHT = 60;
|
const ROLE_NODE_HEIGHT = 60;
|
||||||
const TERMINAL_NODE_SIZE = 40;
|
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 = {
|
type LayoutInput = {
|
||||||
edges: readonly WorkflowGraphEdge[];
|
edges: readonly WorkflowGraphEdge[];
|
||||||
roles: Record<string, { description: string }>;
|
roles: Record<string, { description: string }>;
|
||||||
@@ -21,15 +25,6 @@ type LayoutResult = {
|
|||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
};
|
};
|
||||||
|
|
||||||
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 nodeSize(id: string): { width: number; height: number } {
|
function nodeSize(id: string): { width: number; height: number } {
|
||||||
if (id === START_ID || id === END_ID) {
|
if (id === START_ID || id === END_ID) {
|
||||||
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
|
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
|
||||||
@@ -41,6 +36,140 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
|||||||
return `${e.from}->${e.to}::${e.condition}`;
|
return `${e.from}->${e.to}::${e.condition}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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[][] {
|
||||||
|
// Collect all node IDs
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const e of edges) {
|
||||||
|
ids.add(e.from);
|
||||||
|
ids.add(e.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build adjacency (excluding self-loops)
|
||||||
|
const adj = new Map<string, string[]>();
|
||||||
|
const inEdges = new Map<string, string[]>();
|
||||||
|
for (const id of ids) {
|
||||||
|
adj.set(id, []);
|
||||||
|
inEdges.set(id, []);
|
||||||
|
}
|
||||||
|
// Detect back-edges via DFS to break cycles
|
||||||
|
const backEdges = new Set<string>();
|
||||||
|
{
|
||||||
|
const WHITE = 0;
|
||||||
|
const GRAY = 1;
|
||||||
|
const BLACK = 2;
|
||||||
|
const color = new Map<string, number>();
|
||||||
|
for (const id of ids) color.set(id, WHITE);
|
||||||
|
|
||||||
|
// Temporary full adjacency for cycle detection
|
||||||
|
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) {
|
||||||
|
// Back-edge: u -> v where v is an ancestor
|
||||||
|
backEdges.add(`${u}->${v}`);
|
||||||
|
} else if (c === WHITE) {
|
||||||
|
dfs(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
color.set(u, BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start DFS from __start__ first for determinism
|
||||||
|
if (ids.has(START_ID)) dfs(START_ID);
|
||||||
|
for (const id of ids) {
|
||||||
|
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DAG adjacency (without back-edges)
|
||||||
|
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);
|
||||||
|
inEdges.get(e.to)?.push(e.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest-path ranking via topological order (Kahn's algorithm)
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()!;
|
||||||
|
const curRank = rank.get(cur) ?? 0;
|
||||||
|
for (const next of adj.get(cur) ?? []) {
|
||||||
|
// Longest path: take max
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by rank
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort within layers alphabetically for stability, but __start__ first, __end__ last
|
||||||
|
for (const layer of layers) {
|
||||||
|
layer.sort((a, b) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty layers
|
||||||
|
return layers.filter((l) => l.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRoleNode(
|
function buildRoleNode(
|
||||||
id: string,
|
id: string,
|
||||||
pos: { x: number; y: number },
|
pos: { x: number; y: number },
|
||||||
@@ -72,143 +201,137 @@ function buildTerminalNode(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map<string, ElkExtendedEdge>): Edge<ConditionEdgeData> {
|
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||||
const isFallback = e.condition === "FALLBACK";
|
|
||||||
const key = edgeKey(e);
|
|
||||||
const elkEdge = elkEdgeMap.get(key);
|
|
||||||
|
|
||||||
// Extract ELK's computed label position
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout logic is inherently branchy
|
||||||
let labelX: number | null = null;
|
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
||||||
let labelY: number | null = null;
|
const layers = computeLayersLongestPath(input.edges);
|
||||||
if (elkEdge?.labels && elkEdge.labels.length > 0) {
|
|
||||||
const label = elkEdge.labels[0];
|
// Flatten layers into a rank map (layer index = rank)
|
||||||
if (label.x !== undefined && label.y !== undefined) {
|
const rank = new Map<string, number>();
|
||||||
labelX = label.x + (label.width ?? 0) / 2;
|
for (let i = 0; i < layers.length; i++) {
|
||||||
labelY = label.y + (label.height ?? 0) / 2;
|
for (const id of layers[i]) {
|
||||||
|
rank.set(id, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Horizontal gap between nodes in the same layer
|
||||||
id: key,
|
const H_GAP = 40;
|
||||||
source: e.from,
|
|
||||||
target: e.to,
|
|
||||||
type: "condition",
|
|
||||||
data: {
|
|
||||||
condition: e.condition,
|
|
||||||
conditionDescription: e.conditionDescription,
|
|
||||||
isFallback,
|
|
||||||
elkLabelX: labelX,
|
|
||||||
elkLabelY: labelY,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const elk = new ELK();
|
// Position nodes: each layer is a horizontal row
|
||||||
|
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||||
|
|
||||||
async function computeLayout(input: LayoutInput): Promise<LayoutResult> {
|
// Find max layer width for centering
|
||||||
const ids = collectNodeIds(input.edges);
|
const layerWidths: number[] = [];
|
||||||
|
for (const layer of layers) {
|
||||||
|
let w = 0;
|
||||||
|
for (const id of layer) {
|
||||||
|
w += nodeSize(id).width;
|
||||||
|
}
|
||||||
|
w += (layer.length - 1) * H_GAP;
|
||||||
|
layerWidths.push(w);
|
||||||
|
}
|
||||||
|
const maxLayerWidth = Math.max(...layerWidths, ROLE_NODE_WIDTH);
|
||||||
|
const centerX = maxLayerWidth / 2;
|
||||||
|
|
||||||
const elkNodes: ElkNode[] = [];
|
let y = 0;
|
||||||
for (const id of ids) {
|
for (let li = 0; li < layers.length; li++) {
|
||||||
const size = nodeSize(id);
|
const layer = layers[li];
|
||||||
elkNodes.push({ id, width: size.width, height: size.height });
|
const totalWidth = layerWidths[li];
|
||||||
}
|
let x = centerX - totalWidth / 2;
|
||||||
|
let maxH = 0;
|
||||||
const elkEdges: ElkExtendedEdge[] = input.edges
|
for (const id of layer) {
|
||||||
.filter((e) => e.from !== e.to)
|
const size = nodeSize(id);
|
||||||
.map((e) => ({
|
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
||||||
id: edgeKey(e),
|
x += size.width + H_GAP;
|
||||||
sources: [e.from],
|
if (size.height > maxH) maxH = size.height;
|
||||||
targets: [e.to],
|
}
|
||||||
labels: e.condition !== ""
|
y += maxH + LAYER_GAP;
|
||||||
? [{ text: e.condition, width: Math.max(e.condition.length * 7 + 16, 60), height: 22 }]
|
|
||||||
: [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const graph: ElkNode = {
|
|
||||||
id: "root",
|
|
||||||
layoutOptions: {
|
|
||||||
"elk.algorithm": "layered",
|
|
||||||
"elk.direction": "DOWN",
|
|
||||||
// Node spacing
|
|
||||||
"elk.spacing.nodeNode": "30",
|
|
||||||
"elk.layered.spacing.nodeNodeBetweenLayers": "50",
|
|
||||||
// Edge spacing — keep edges apart from each other and from nodes
|
|
||||||
"elk.spacing.edgeNode": "25",
|
|
||||||
"elk.spacing.edgeEdge": "15",
|
|
||||||
"elk.layered.spacing.edgeNodeBetweenLayers": "25",
|
|
||||||
"elk.layered.spacing.edgeEdgeBetweenLayers": "15",
|
|
||||||
// Edge routing
|
|
||||||
"elk.edgeRouting": "ORTHOGONAL",
|
|
||||||
"elk.layered.mergeEdges": "false",
|
|
||||||
// Node placement
|
|
||||||
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
|
||||||
// Edge label placement
|
|
||||||
"elk.edgeLabels.placement": "CENTER",
|
|
||||||
// Crossing minimization
|
|
||||||
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
|
||||||
// Compaction
|
|
||||||
"elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH",
|
|
||||||
// Cycle breaking — keep main flow top-to-bottom
|
|
||||||
"elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
|
|
||||||
},
|
|
||||||
children: elkNodes,
|
|
||||||
edges: elkEdges,
|
|
||||||
};
|
|
||||||
|
|
||||||
const laid = await elk.layout(graph);
|
|
||||||
|
|
||||||
// Build map of ELK edge results for label positions
|
|
||||||
const elkEdgeMap = new Map<string, ElkExtendedEdge>();
|
|
||||||
for (const e of laid.edges ?? []) {
|
|
||||||
elkEdgeMap.set(e.id, e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build nodes
|
||||||
const nodes: Node[] = [];
|
const nodes: Node[] = [];
|
||||||
for (const child of laid.children ?? []) {
|
for (const layer of layers) {
|
||||||
const pos = { x: child.x ?? 0, y: child.y ?? 0 };
|
for (const id of layer) {
|
||||||
const state = input.nodeStates.get(child.id) ?? "default";
|
const pos = nodePositions.get(id);
|
||||||
if (child.id === START_ID || child.id === END_ID) {
|
if (pos === undefined) continue;
|
||||||
nodes.push(buildTerminalNode(child.id, pos, state));
|
const state = input.nodeStates.get(id) ?? "default";
|
||||||
} else {
|
if (id === START_ID || id === END_ID) {
|
||||||
nodes.push(buildRoleNode(child.id, pos, input.roles, state));
|
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
|
||||||
|
} else {
|
||||||
|
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap));
|
// Build edges with label positions
|
||||||
|
const routedCountByTarget = new Map<string, number>();
|
||||||
|
const edges: Edge[] = input.edges.map((e) => {
|
||||||
|
const isFallback = e.condition === "FALLBACK";
|
||||||
|
const isSelfLoop = e.from === e.to;
|
||||||
|
const sourceRank = rank.get(e.from) ?? 0;
|
||||||
|
const targetRank = rank.get(e.to) ?? 0;
|
||||||
|
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||||
|
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
|
||||||
|
|
||||||
|
const sourcePos = nodePositions.get(e.from);
|
||||||
|
const targetPos = nodePositions.get(e.to);
|
||||||
|
|
||||||
|
let labelX: number | null = null;
|
||||||
|
let labelY: number | null = null;
|
||||||
|
let feedbackSide: "right" | "left" | null = null;
|
||||||
|
|
||||||
|
if (sourcePos !== undefined && targetPos !== undefined) {
|
||||||
|
if (isFeedback || isSkipForward) {
|
||||||
|
const count = routedCountByTarget.get(e.to) ?? 0;
|
||||||
|
routedCountByTarget.set(e.to, count + 1);
|
||||||
|
feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||||
|
const offsetX =
|
||||||
|
feedbackSide === "right"
|
||||||
|
? centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||||
|
: centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||||
|
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||||
|
labelX = offsetX;
|
||||||
|
labelY = midY;
|
||||||
|
} else if (!isSelfLoop) {
|
||||||
|
const midX = centerX;
|
||||||
|
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||||
|
labelX = midX;
|
||||||
|
labelY = midY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: edgeKey(e),
|
||||||
|
source: e.from,
|
||||||
|
target: e.to,
|
||||||
|
sourceHandle:
|
||||||
|
isFeedback || isSkipForward
|
||||||
|
? feedbackSide === "left"
|
||||||
|
? "left-out"
|
||||||
|
: "right-out"
|
||||||
|
: "bottom-out",
|
||||||
|
targetHandle:
|
||||||
|
isFeedback || isSkipForward ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||||
|
type: "condition",
|
||||||
|
data: {
|
||||||
|
condition: e.condition,
|
||||||
|
conditionDescription: e.conditionDescription,
|
||||||
|
isFallback,
|
||||||
|
isFeedback: isFeedback || isSkipForward,
|
||||||
|
isSelfLoop,
|
||||||
|
feedbackSide,
|
||||||
|
labelX,
|
||||||
|
labelY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_LAYOUT: LayoutResult = { nodes: [], edges: [] };
|
// ── Public hook ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useLayout(input: LayoutInput): LayoutResult {
|
export function useLayout(input: LayoutInput): LayoutResult {
|
||||||
const [layout, setLayout] = useState<LayoutResult>(EMPTY_LAYOUT);
|
return useMemo(() => computeLayoutLongestPath(input), [input]);
|
||||||
|
|
||||||
const edgeJson = JSON.stringify(input.edges);
|
|
||||||
const roleJson = JSON.stringify(input.roles);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const parsed = {
|
|
||||||
edges: JSON.parse(edgeJson) as readonly WorkflowGraphEdge[],
|
|
||||||
roles: JSON.parse(roleJson) as Record<string, { description: string }>,
|
|
||||||
nodeStates: input.nodeStates,
|
|
||||||
};
|
|
||||||
computeLayout(parsed)
|
|
||||||
.then((result) => {
|
|
||||||
if (!cancelled) setLayout(result);
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
// biome-ignore lint/suspicious/noConsole: layout error reporting
|
|
||||||
console.error("ELK layout failed:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [edgeJson, roleJson, input.nodeStates]);
|
|
||||||
|
|
||||||
return layout;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
type OnNodeClick,
|
type OnNodeClick,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
|
||||||
useReactFlow,
|
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
||||||
import { ConditionEdge } from "./condition-edge.tsx";
|
import { ConditionEdge } from "./condition-edge.tsx";
|
||||||
import { RoleNode } from "./role-node.tsx";
|
import { RoleNode } from "./role-node.tsx";
|
||||||
@@ -34,34 +32,16 @@ const edgeTypes: EdgeTypes = {
|
|||||||
condition: ConditionEdge,
|
condition: ConditionEdge,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node): void {
|
function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): void {
|
||||||
if (node.type !== "role") return;
|
if (node.type !== "role" && node.type !== "terminal") return;
|
||||||
onRoleClick(node.id);
|
onNodeClick(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||||
const { fitView } = useReactFlow();
|
|
||||||
|
|
||||||
const onNodeClickHandler: OnNodeClick | undefined =
|
const onNodeClickHandler: OnNodeClick | undefined =
|
||||||
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
|
||||||
|
|
||||||
// Re-fit when layout changes (ELK is async)
|
|
||||||
// Use requestAnimationFrame + setTimeout to ensure ReactFlow has processed nodes
|
|
||||||
useEffect(() => {
|
|
||||||
if (layout.nodes.length > 0) {
|
|
||||||
let cancelled = false;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!cancelled) fitView({ padding: 0.1, duration: 300 });
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [layout.nodes, layout.edges, fitView]);
|
|
||||||
|
|
||||||
const styledEdges = useMemo(
|
const styledEdges = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -77,25 +57,17 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
|||||||
[layout.edges],
|
[layout.edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate a stable key that changes when layout changes, to force ReactFlow remount + fitView
|
|
||||||
const layoutKey = useMemo(
|
|
||||||
() => layout.nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","),
|
|
||||||
[layout.nodes],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
key={layoutKey}
|
|
||||||
nodes={layout.nodes}
|
nodes={layout.nodes}
|
||||||
edges={styledEdges}
|
edges={styledEdges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
onNodeClick={onNodeClickHandler}
|
onNodeClick={onNodeClickHandler}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.1, minZoom: 0.1, maxZoom: 1.5 }}
|
fitViewOptions={{ padding: 0.15 }}
|
||||||
minZoom={0.1}
|
minZoom={0.3}
|
||||||
maxZoom={1.5}
|
maxZoom={2}
|
||||||
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
|
||||||
nodesDraggable={false}
|
nodesDraggable={false}
|
||||||
nodesConnectable={false}
|
nodesConnectable={false}
|
||||||
elementsSelectable={false}
|
elementsSelectable={false}
|
||||||
@@ -107,11 +79,3 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
|||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowGraph(props: Props) {
|
|
||||||
return (
|
|
||||||
<ReactFlowProvider>
|
|
||||||
<WorkflowGraphInner {...props} />
|
|
||||||
</ReactFlowProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,174 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { listWorkflows } from "../api.ts";
|
||||||
import type { WorkflowDetail } from "../api.ts";
|
|
||||||
import { getWorkflowDetail, listWorkflows } from "../api.ts";
|
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string;
|
client: string;
|
||||||
|
onSelect: (name: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DetailCacheEntry =
|
export function WorkflowList({ client, onSelect }: Props) {
|
||||||
| { status: "loading" }
|
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
||||||
| { status: "error"; message: string }
|
|
||||||
| { status: "ok"; detail: WorkflowDetail };
|
|
||||||
|
|
||||||
function versionCount(detail: WorkflowDetail): number {
|
|
||||||
return detail.history.length + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpandedWorkflowBody({
|
|
||||||
cacheEntry,
|
|
||||||
staticNodeStates,
|
|
||||||
}: {
|
|
||||||
cacheEntry: DetailCacheEntry | undefined;
|
|
||||||
staticNodeStates: Map<string, NodeState>;
|
|
||||||
}) {
|
|
||||||
if (cacheEntry === undefined || cacheEntry.status === "loading") {
|
|
||||||
return (
|
|
||||||
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Loading workflow details...
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheEntry.status === "error") {
|
|
||||||
return (
|
|
||||||
<p className="text-sm py-2" style={{ color: "var(--color-error)" }}>
|
|
||||||
{cacheEntry.message}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { detail } = cacheEntry;
|
|
||||||
const descriptor = detail.descriptor;
|
|
||||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
|
||||||
const vc = versionCount(detail);
|
|
||||||
|
|
||||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="pt-3 border-t flex gap-4"
|
|
||||||
style={{ borderColor: "var(--color-border)" }}
|
|
||||||
>
|
|
||||||
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
|
||||||
{detail.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Hash
|
|
||||||
</p>
|
|
||||||
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
|
||||||
{detail.hash}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
{vc} version{vc !== 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Description
|
|
||||||
</p>
|
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
|
||||||
{descriptor !== null && descriptor.description !== ""
|
|
||||||
? descriptor.description
|
|
||||||
: descriptor !== null
|
|
||||||
? "—"
|
|
||||||
: "No descriptor available for this workflow version."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasGraph ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border overflow-hidden flex-1"
|
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 text-xs flex justify-between items-center"
|
|
||||||
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
|
|
||||||
>
|
|
||||||
<span className="font-mono">Workflow graph</span>
|
|
||||||
<span>
|
|
||||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ height: 600, width: "100%" }}>
|
|
||||||
<WorkflowGraph
|
|
||||||
graph={descriptor.graph}
|
|
||||||
roles={descriptor.roles}
|
|
||||||
nodeStates={staticNodeStates}
|
|
||||||
onNodeClick={null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowList({ agent }: Props) {
|
|
||||||
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
|
||||||
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
|
|
||||||
() => new Map(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching agents
|
|
||||||
useEffect(() => {
|
|
||||||
setExpanded(new Set());
|
|
||||||
setDetailsByName(new Map());
|
|
||||||
}, [agent]);
|
|
||||||
|
|
||||||
const ensureDetailLoaded = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
setDetailsByName((prev) => {
|
|
||||||
const cur = prev.get(name);
|
|
||||||
if (cur !== undefined && (cur.status === "ok" || cur.status === "loading")) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return new Map(prev).set(name, { status: "loading" });
|
|
||||||
});
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const detail = await getWorkflowDetail(agent, name);
|
|
||||||
setDetailsByName((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(name, { status: "ok", detail });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
|
||||||
setDetailsByName((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(name, { status: "error", message });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
[agent],
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggleExpanded(name: string) {
|
|
||||||
const wasExpanded = expanded.has(name);
|
|
||||||
setExpanded((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(name)) {
|
|
||||||
next.delete(name);
|
|
||||||
} else {
|
|
||||||
next.add(name);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (!wasExpanded) {
|
|
||||||
ensureDetailLoaded(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "loading")
|
if (status === "loading")
|
||||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||||
@@ -183,58 +22,34 @@ export function WorkflowList({ agent }: Props) {
|
|||||||
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{workflows.map((w) => {
|
{workflows.map((w) => (
|
||||||
const isOpen = expanded.has(w.name);
|
<button
|
||||||
return (
|
key={w.name}
|
||||||
<div
|
type="button"
|
||||||
key={w.name}
|
onClick={() => onSelect(w.name)}
|
||||||
className="rounded-lg border overflow-hidden"
|
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
style={{
|
||||||
>
|
background: "var(--color-surface)",
|
||||||
<button
|
borderColor: "var(--color-border)",
|
||||||
type="button"
|
color: "var(--color-text)",
|
||||||
onClick={() => toggleExpanded(w.name)}
|
}}
|
||||||
className="w-full text-left p-4 flex items-start justify-between gap-3 hover:opacity-90"
|
>
|
||||||
style={{ color: "var(--color-text)" }}
|
<div className="flex items-center gap-2">
|
||||||
aria-expanded={isOpen}
|
<span className="font-medium">{w.name}</span>
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="text-xs font-mono"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{isOpen ? "▼" : "▶"}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{w.name}</span>
|
|
||||||
</div>
|
|
||||||
<code
|
|
||||||
className="text-xs mt-1 block font-mono truncate"
|
|
||||||
style={{ color: "var(--color-accent)" }}
|
|
||||||
>
|
|
||||||
{w.hash !== null ? w.hash : "—"}
|
|
||||||
</code>
|
|
||||||
{w.timestamp !== null ? (
|
|
||||||
<span
|
|
||||||
className="text-xs mt-1 block"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
Updated {new Date(w.timestamp).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{isOpen ? (
|
|
||||||
<div className="px-4 pb-4">
|
|
||||||
<ExpandedWorkflowBody
|
|
||||||
cacheEntry={detailsByName.get(w.name)}
|
|
||||||
staticNodeStates={staticNodeStates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<code
|
||||||
})}
|
className="text-xs mt-1 block font-mono truncate"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{w.hash !== null ? w.hash : "—"}
|
||||||
|
</code>
|
||||||
|
{w.timestamp !== null ? (
|
||||||
|
<span className="text-xs mt-1 block" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Updated {new Date(w.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,36 +4,42 @@ type View = "threads" | "workflows";
|
|||||||
|
|
||||||
type HashRoute = {
|
type HashRoute = {
|
||||||
view: View;
|
view: View;
|
||||||
agent: string | null;
|
client: string | null;
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
|
workflowName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseHash(hash: string): HashRoute {
|
function parseHash(hash: string): HashRoute {
|
||||||
const raw = hash.replace(/^#\/?/, "");
|
const raw = hash.replace(/^#\/?/, "");
|
||||||
// Format: #agent/threads/id or #agent/workflows or #threads or #workflows
|
// Format: #client/threads/id or #client/workflows or #threads or #workflows
|
||||||
const parts = raw.split("/");
|
const parts = raw.split("/");
|
||||||
|
|
||||||
// Check if first part is a known view
|
// Check if first part is a known view
|
||||||
if (parts[0] === "threads" || parts[0] === "workflows") {
|
if (parts[0] === "threads" || parts[0] === "workflows") {
|
||||||
return {
|
return {
|
||||||
view: parts[0] as View,
|
view: parts[0] as View,
|
||||||
agent: null,
|
client: null,
|
||||||
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||||
|
workflowName: parts[0] === "workflows" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// First part is agent name
|
// First part is client name
|
||||||
const agent = parts[0] || null;
|
const client = parts[0] || null;
|
||||||
const viewPart = parts[1] ?? "threads";
|
const viewPart = parts[1] ?? "threads";
|
||||||
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
||||||
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||||
|
const workflowName = view === "workflows" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||||
|
|
||||||
return { view, agent, threadId };
|
return { view, client, threadId, workflowName };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHash(route: HashRoute): string {
|
function buildHash(route: HashRoute): string {
|
||||||
const prefix = route.agent ? `${route.agent}/` : "";
|
const prefix = route.client ? `${route.client}/` : "";
|
||||||
if (route.view === "workflows") {
|
if (route.view === "workflows") {
|
||||||
|
if (route.workflowName !== null) {
|
||||||
|
return `#${prefix}workflows/${route.workflowName}`;
|
||||||
|
}
|
||||||
return `#${prefix}workflows`;
|
return `#${prefix}workflows`;
|
||||||
}
|
}
|
||||||
if (route.threadId !== null) {
|
if (route.threadId !== null) {
|
||||||
@@ -44,11 +50,13 @@ function buildHash(route: HashRoute): string {
|
|||||||
|
|
||||||
export function useHashRoute(): {
|
export function useHashRoute(): {
|
||||||
view: View;
|
view: View;
|
||||||
agent: string | null;
|
client: string | null;
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
|
workflowName: string | null;
|
||||||
setView: (v: View) => void;
|
setView: (v: View) => void;
|
||||||
setAgent: (a: string | null) => void;
|
setClient: (a: string | null) => void;
|
||||||
setThreadId: (id: string | null) => void;
|
setThreadId: (id: string | null) => void;
|
||||||
|
setWorkflowName: (name: string | null) => void;
|
||||||
} {
|
} {
|
||||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||||
|
|
||||||
@@ -67,26 +75,36 @@ export function useHashRoute(): {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setView = useCallback(
|
const setView = useCallback(
|
||||||
(v: View) => navigate({ view: v, agent: route.agent, threadId: null }),
|
(v: View) => navigate({ view: v, client: route.client, threadId: null, workflowName: null }),
|
||||||
[navigate, route.agent],
|
[navigate, route.client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setAgent = useCallback(
|
const setClient = useCallback(
|
||||||
(a: string | null) => navigate({ view: route.view, agent: a, threadId: null }),
|
(a: string | null) =>
|
||||||
|
navigate({ view: route.view, client: a, threadId: null, workflowName: null }),
|
||||||
[navigate, route.view],
|
[navigate, route.view],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThreadId = useCallback(
|
const setThreadId = useCallback(
|
||||||
(id: string | null) => navigate({ view: "threads", agent: route.agent, threadId: id }),
|
(id: string | null) =>
|
||||||
[navigate, route.agent],
|
navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }),
|
||||||
|
[navigate, route.client],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWorkflowName = useCallback(
|
||||||
|
(name: string | null) =>
|
||||||
|
navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }),
|
||||||
|
[navigate, route.client],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
view: route.view,
|
view: route.view,
|
||||||
agent: route.agent,
|
client: route.client,
|
||||||
threadId: route.threadId,
|
threadId: route.threadId,
|
||||||
|
workflowName: route.workflowName,
|
||||||
setView,
|
setView,
|
||||||
setAgent,
|
setClient,
|
||||||
setThreadId,
|
setThreadId,
|
||||||
|
setWorkflowName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,17 +57,17 @@ function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
|
|||||||
ctx.cleanupEs();
|
ctx.cleanupEs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sseUrl(agent: string, threadId: string): string {
|
function sseUrl(client: string, threadId: string): string {
|
||||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
||||||
const key = getApiKey();
|
const key = getApiKey();
|
||||||
const keyParam = key ? `?key=${encodeURIComponent(key)}` : "";
|
const keyParam = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||||
if (gatewayUrl) {
|
if (gatewayUrl) {
|
||||||
return `${gatewayUrl}/api/${agent}/threads/${encodeURIComponent(threadId)}/live${keyParam}`;
|
return `${gatewayUrl}/api/${client}/threads/${encodeURIComponent(threadId)}/live${keyParam}`;
|
||||||
}
|
}
|
||||||
return `/api/threads/${encodeURIComponent(threadId)}/live`;
|
return `/api/threads/${encodeURIComponent(threadId)}/live`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSSE(agent: string | null, threadId: string | null): UseSSEReturn {
|
export function useSSE(client: string | null, threadId: string | null): UseSSEReturn {
|
||||||
const [records, setRecords] = useState<ThreadRecord[]>([]);
|
const [records, setRecords] = useState<ThreadRecord[]>([]);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [completed, setCompleted] = useState(false);
|
const [completed, setCompleted] = useState(false);
|
||||||
@@ -76,7 +76,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
|||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (threadId === null || agent === null) {
|
if (threadId === null || client === null) {
|
||||||
completedRef.current = false;
|
completedRef.current = false;
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
setRecords([]);
|
setRecords([]);
|
||||||
@@ -86,7 +86,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tid = threadId;
|
const tid = threadId;
|
||||||
const agentName = agent;
|
const clientName = client;
|
||||||
|
|
||||||
completedRef.current = false;
|
completedRef.current = false;
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
@@ -125,7 +125,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanupEs();
|
cleanupEs();
|
||||||
const url = sseUrl(agentName, tid);
|
const url = sseUrl(clientName, tid);
|
||||||
es = new EventSource(url);
|
es = new EventSource(url);
|
||||||
|
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
@@ -177,7 +177,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
|||||||
}
|
}
|
||||||
cleanupEs();
|
cleanupEs();
|
||||||
};
|
};
|
||||||
}, [agent, threadId]);
|
}, [client, threadId]);
|
||||||
|
|
||||||
return { records, connected, completed };
|
return { records, connected, completed };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# @uncaged/workflow-execute
|
||||||
|
|
||||||
|
## 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-reactor@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-reactor@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-reactor@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-protocol@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-reactor@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-reactor@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
|
||||||
|
|
||||||
|
## 0.4.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.4.5
|
||||||
|
- @uncaged/workflow-cas@0.4.5
|
||||||
|
- @uncaged/workflow-reactor@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-reactor@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-protocol@0.4.3
|
||||||
|
- @uncaged/workflow-reactor@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-protocol@0.4.2
|
||||||
|
- @uncaged/workflow-reactor@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-protocol@0.4.0
|
||||||
|
- @uncaged/workflow-reactor@0.4.0
|
||||||
|
- @uncaged/workflow-register@0.4.0
|
||||||
|
- @uncaged/workflow-runtime@0.4.0
|
||||||
|
- @uncaged/workflow-util@0.4.0
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-execute",
|
"name": "@uncaged/workflow-execute",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:*",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-runtime": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:*",
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
"@uncaged/workflow-cas": "workspace:*",
|
"@uncaged/workflow-cas": "workspace:^",
|
||||||
"@uncaged/workflow-reactor": "workspace:*",
|
"@uncaged/workflow-reactor": "workspace:^",
|
||||||
"@uncaged/workflow-register": "workspace:*",
|
"@uncaged/workflow-register": "workspace:^",
|
||||||
"yaml": "^2.7.1"
|
"yaml": "^2.7.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -29,5 +31,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createServer, type Socket } from "node:net";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { createCasStore } from "@uncaged/workflow-cas";
|
import { createCasStore } from "@uncaged/workflow-cas";
|
||||||
import {
|
import {
|
||||||
ensureUncagedWorkflowSymlink,
|
|
||||||
importWorkflowBundleModule,
|
importWorkflowBundleModule,
|
||||||
} from "@uncaged/workflow-register";
|
} from "@uncaged/workflow-register";
|
||||||
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
|
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||||
@@ -365,7 +364,6 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureUncagedWorkflowSymlink(storageRoot);
|
|
||||||
// Dynamic import required: user bundle path resolved at runtime
|
// Dynamic import required: user bundle path resolved at runtime
|
||||||
const modUnknown: unknown = await importWorkflowBundleModule(bundlePath);
|
const modUnknown: unknown = await importWorkflowBundleModule(bundlePath);
|
||||||
const modRec = modUnknown as Record<string, unknown>;
|
const modRec = modUnknown as Record<string, unknown>;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async function resolveWorkflowBundle(workflowName: string, storageRoot: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||||
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
|
const bundleExportsResult = await extractBundleExports(bundlePath);
|
||||||
if (!bundleExportsResult.ok) {
|
if (!bundleExportsResult.ok) {
|
||||||
throw new Error(String(bundleExportsResult.error));
|
throw new Error(String(bundleExportsResult.error));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# @uncaged/workflow-gateway
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
## 0.4.5
|
||||||
|
|
||||||
|
## 0.4.4
|
||||||
|
|
||||||
|
## 0.4.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||||
|
|
||||||
|
## 0.4.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Fix package exports for published packages and adopt changesets for version management.
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-gateway",
|
"name": "@uncaged/workflow-gateway",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
@@ -20,5 +21,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260425.1",
|
"@cloudflare/workers-types": "^4.20260425.1",
|
||||||
"wrangler": "^4.20.0"
|
"wrangler": "^4.20.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -1,14 +1,14 @@
|
|||||||
/** One Durable Object instance per agent name; holds the reverse WebSocket from the agent CLI. */
|
/** One Durable Object instance per client name; holds the reverse WebSocket from the client CLI. */
|
||||||
import { DurableObject } from "cloudflare:workers";
|
import { DurableObject } from "cloudflare:workers";
|
||||||
|
|
||||||
import { parseWsRequestJson, parseWsResponseJson, type WsResponse } from "./ws-protocol.js";
|
import { parseWsRequestJson, parseWsResponseJson, type WsResponse } from "./ws-protocol.js";
|
||||||
|
|
||||||
type AgentSocketEnv = {
|
type ClientSocketEnv = {
|
||||||
GATEWAY_SECRET: string;
|
WORKFLOW_DASHBOARD_SECRET: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AGENT_SOCKET_INTERNAL_STATUS_PATH = "/internal/agent-socket/status";
|
export const CLIENT_SOCKET_INTERNAL_STATUS_PATH = "/internal/client-socket/status";
|
||||||
export const AGENT_SOCKET_INTERNAL_PROXY_PATH = "/internal/agent-socket/proxy";
|
export const CLIENT_SOCKET_INTERNAL_PROXY_PATH = "/internal/client-socket/proxy";
|
||||||
|
|
||||||
const PROXY_TIMEOUT_MS = 30_000;
|
const PROXY_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
@@ -32,12 +32,12 @@ function wsResponseToHttp(wr: WsResponse): Response {
|
|||||||
return new Response(wr.body, { status: wr.status, headers });
|
return new Response(wr.body, { status: wr.status, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
export class ClientSocket extends DurableObject<ClientSocketEnv> {
|
||||||
private readonly pending = new Map<string, PendingEntry>();
|
private readonly pending = new Map<string, PendingEntry>();
|
||||||
|
|
||||||
private requireAuth(request: Request): Response | null {
|
private requireAuth(request: Request): Response | null {
|
||||||
const auth = request.headers.get("Authorization");
|
const auth = request.headers.get("Authorization");
|
||||||
if (auth !== `Bearer ${this.env.GATEWAY_SECRET}`) {
|
if (auth !== `Bearer ${this.env.WORKFLOW_DASHBOARD_SECRET}`) {
|
||||||
return jsonResponse(401, { error: "unauthorized" });
|
return jsonResponse(401, { error: "unauthorized" });
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -100,11 +100,11 @@ export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
|||||||
async fetch(request: Request): Promise<Response> {
|
async fetch(request: Request): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
if (url.pathname === AGENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
|
if (url.pathname === CLIENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
|
||||||
return this.handleStatusGet(request);
|
return this.handleStatusGet(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === AGENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
|
if (url.pathname === CLIENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
|
||||||
return this.handleProxyPost(request);
|
return this.handleProxyPost(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,11 +144,11 @@ export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
|||||||
_reason: string,
|
_reason: string,
|
||||||
_wasClean: boolean,
|
_wasClean: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.rejectAllPending("agent websocket closed");
|
this.rejectAllPending("client websocket closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
async webSocketError(_ws: WebSocket, _error: unknown): Promise<void> {
|
async webSocketError(_ws: WebSocket, _error: unknown): Promise<void> {
|
||||||
this.rejectAllPending("agent websocket error");
|
this.rejectAllPending("client websocket error");
|
||||||
}
|
}
|
||||||
|
|
||||||
private rejectAllPending(message: string): void {
|
private rejectAllPending(message: string): void {
|
||||||
@@ -2,27 +2,26 @@ import { Hono } from "hono";
|
|||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AGENT_SOCKET_INTERNAL_PROXY_PATH,
|
CLIENT_SOCKET_INTERNAL_PROXY_PATH,
|
||||||
AGENT_SOCKET_INTERNAL_STATUS_PATH,
|
CLIENT_SOCKET_INTERNAL_STATUS_PATH,
|
||||||
AgentSocket,
|
ClientSocket,
|
||||||
} from "./agent-socket.js";
|
} from "./client-socket.js";
|
||||||
import type { WsRequest } from "./ws-protocol.js";
|
import type { WsRequest } from "./ws-protocol.js";
|
||||||
|
|
||||||
export { AgentSocket };
|
export { ClientSocket };
|
||||||
|
|
||||||
type Env = {
|
type Env = {
|
||||||
Bindings: {
|
Bindings: {
|
||||||
ENDPOINTS: KVNamespace;
|
ENDPOINTS: KVNamespace;
|
||||||
GATEWAY_SECRET: string;
|
WORKFLOW_DASHBOARD_SECRET: string;
|
||||||
DASHBOARD_API_KEY: string;
|
CLIENT_SOCKET: DurableObjectNamespace<ClientSocket>;
|
||||||
AGENT_SOCKET: DurableObjectNamespace<AgentSocket>;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type EndpointRecord = {
|
type EndpointRecord = {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
agentToken: string;
|
clientToken: string;
|
||||||
registeredAt: number;
|
registeredAt: number;
|
||||||
lastHeartbeat: number;
|
lastHeartbeat: number;
|
||||||
};
|
};
|
||||||
@@ -40,10 +39,10 @@ function checkDashboardAuth(c: {
|
|||||||
const bearer = c.req.header("Authorization")?.replace("Bearer ", "");
|
const bearer = c.req.header("Authorization")?.replace("Bearer ", "");
|
||||||
const query = c.req.query("key");
|
const query = c.req.query("key");
|
||||||
const key = bearer ?? query;
|
const key = bearer ?? query;
|
||||||
return key === c.env.DASHBOARD_API_KEY;
|
return key === c.env.WORKFLOW_DASHBOARD_SECRET;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLocalAgentUrl(url: string): boolean {
|
function isLocalClientUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
return u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
return u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||||
@@ -52,7 +51,7 @@ function isLocalAgentUrl(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildForwardHeaders(raw: Headers, agentToken: string): Record<string, string> {
|
function buildForwardHeaders(raw: Headers, clientToken: string): Record<string, string> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const [key, value] of raw) {
|
for (const [key, value] of raw) {
|
||||||
const lower = key.toLowerCase();
|
const lower = key.toLowerCase();
|
||||||
@@ -70,8 +69,8 @@ function buildForwardHeaders(raw: Headers, agentToken: string): Record<string, s
|
|||||||
}
|
}
|
||||||
out[key] = value;
|
out[key] = value;
|
||||||
}
|
}
|
||||||
if (agentToken !== "") {
|
if (clientToken !== "") {
|
||||||
out["X-Agent-Token"] = agentToken;
|
out["X-Client-Token"] = clientToken;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -81,7 +80,7 @@ function buildDashboardProxyHeaders(raw: Headers, token: string): Headers {
|
|||||||
headers.delete("host");
|
headers.delete("host");
|
||||||
headers.delete("Authorization");
|
headers.delete("Authorization");
|
||||||
if (token !== "") {
|
if (token !== "") {
|
||||||
headers.set("X-Agent-Token", token);
|
headers.set("X-Client-Token", token);
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
@@ -94,15 +93,15 @@ async function readBodyForWsProxy(method: string, req: Request): Promise<string
|
|||||||
return buf.byteLength === 0 ? null : new TextDecoder().decode(buf);
|
return buf.byteLength === 0 ? null : new TextDecoder().decode(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchThroughAgentSocket(
|
async function fetchThroughClientSocket(
|
||||||
bindings: Env["Bindings"],
|
bindings: Env["Bindings"],
|
||||||
agent: string,
|
client: string,
|
||||||
gateSecret: string,
|
gateSecret: string,
|
||||||
wsRequest: WsRequest,
|
wsRequest: WsRequest,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const stub = bindings.AGENT_SOCKET.get(bindings.AGENT_SOCKET.idFromName(agent));
|
const stub = bindings.CLIENT_SOCKET.get(bindings.CLIENT_SOCKET.idFromName(client));
|
||||||
return stub.fetch(
|
return stub.fetch(
|
||||||
new Request(`https://do.internal${AGENT_SOCKET_INTERNAL_PROXY_PATH}`, {
|
new Request(`https://do.internal${CLIENT_SOCKET_INTERNAL_PROXY_PATH}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${gateSecret}`,
|
Authorization: `Bearer ${gateSecret}`,
|
||||||
@@ -113,7 +112,7 @@ async function fetchThroughAgentSocket(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAgentWithRecordHeaders(
|
async function fetchClientWithRecordHeaders(
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
method: string,
|
method: string,
|
||||||
forwardRecord: Record<string, string>,
|
forwardRecord: Record<string, string>,
|
||||||
@@ -130,7 +129,7 @@ async function fetchAgentWithRecordHeaders(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAgentWithDashboardHeaders(
|
async function fetchClientWithDashboardHeaders(
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
method: string,
|
method: string,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
@@ -143,17 +142,17 @@ async function fetchAgentWithDashboardHeaders(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAgentSocketStatus(
|
async function fetchClientSocketStatus(
|
||||||
env: Env["Bindings"],
|
env: Env["Bindings"],
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<{ ok: true; connected: boolean } | { ok: false }> {
|
): Promise<{ ok: true; connected: boolean } | { ok: false }> {
|
||||||
try {
|
try {
|
||||||
const id = env.AGENT_SOCKET.idFromName(name);
|
const id = env.CLIENT_SOCKET.idFromName(name);
|
||||||
const stub = env.AGENT_SOCKET.get(id);
|
const stub = env.CLIENT_SOCKET.get(id);
|
||||||
const resp = await stub.fetch(
|
const resp = await stub.fetch(
|
||||||
new Request(`https://do${AGENT_SOCKET_INTERNAL_STATUS_PATH}`, {
|
new Request(`https://do${CLIENT_SOCKET_INTERNAL_STATUS_PATH}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { Authorization: `Bearer ${env.GATEWAY_SECRET}` },
|
headers: { Authorization: `Bearer ${env.WORKFLOW_DASHBOARD_SECRET}` },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -171,7 +170,7 @@ function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean
|
|||||||
return "online";
|
return "online";
|
||||||
}
|
}
|
||||||
if (doConnected === false) {
|
if (doConnected === false) {
|
||||||
if (isLocalAgentUrl(record.url)) {
|
if (isLocalClientUrl(record.url)) {
|
||||||
return "offline";
|
return "offline";
|
||||||
}
|
}
|
||||||
const age = Date.now() - record.lastHeartbeat;
|
const age = Date.now() - record.lastHeartbeat;
|
||||||
@@ -184,25 +183,25 @@ function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean
|
|||||||
// ── Health ──────────────────────────────────────────────────────────
|
// ── Health ──────────────────────────────────────────────────────────
|
||||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
// ── Agent reverse WebSocket (GATEWAY_SECRET query param) ────────────
|
// ── Client reverse WebSocket (WORKFLOW_DASHBOARD_SECRET query param) ────────────
|
||||||
app.get("/ws/connect", async (c) => {
|
app.get("/ws/connect", async (c) => {
|
||||||
const secret = c.req.query("secret");
|
const secret = c.req.query("secret");
|
||||||
const name = c.req.query("name");
|
const name = c.req.query("name");
|
||||||
if (name === undefined || name === "") {
|
if (name === undefined || name === "") {
|
||||||
return c.json({ error: "name required" }, 400);
|
return c.json({ error: "name required" }, 400);
|
||||||
}
|
}
|
||||||
if (secret !== c.env.GATEWAY_SECRET) {
|
if (secret !== c.env.WORKFLOW_DASHBOARD_SECRET) {
|
||||||
return c.json({ error: "unauthorized" }, 401);
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
if (c.req.header("Upgrade") !== "websocket") {
|
if (c.req.header("Upgrade") !== "websocket") {
|
||||||
return c.text("expected WebSocket upgrade", 426);
|
return c.text("expected WebSocket upgrade", 426);
|
||||||
}
|
}
|
||||||
const id = c.env.AGENT_SOCKET.idFromName(name);
|
const id = c.env.CLIENT_SOCKET.idFromName(name);
|
||||||
const stub = c.env.AGENT_SOCKET.get(id);
|
const stub = c.env.CLIENT_SOCKET.get(id);
|
||||||
return stub.fetch(c.req.raw);
|
return stub.fetch(c.req.raw);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Gateway management (GATEWAY_SECRET auth) ────────────────────────
|
// ── Gateway management (WORKFLOW_DASHBOARD_SECRET auth) ────────────────────────
|
||||||
const gateway = new Hono<Env>();
|
const gateway = new Hono<Env>();
|
||||||
|
|
||||||
gateway.post("/register", async (c) => {
|
gateway.post("/register", async (c) => {
|
||||||
@@ -210,14 +209,14 @@ gateway.post("/register", async (c) => {
|
|||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
secret?: string;
|
secret?: string;
|
||||||
agentToken?: string;
|
clientToken?: string;
|
||||||
}>();
|
}>();
|
||||||
const { name, url, secret, agentToken } = body;
|
const { name, url, secret, clientToken } = body;
|
||||||
|
|
||||||
if (!name || !url) {
|
if (!name || !url) {
|
||||||
return c.json({ error: "name and url required" }, 400);
|
return c.json({ error: "name and url required" }, 400);
|
||||||
}
|
}
|
||||||
if (secret !== c.env.GATEWAY_SECRET) {
|
if (secret !== c.env.WORKFLOW_DASHBOARD_SECRET) {
|
||||||
return c.json({ error: "unauthorized" }, 401);
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +226,7 @@ gateway.post("/register", async (c) => {
|
|||||||
const record: EndpointRecord = {
|
const record: EndpointRecord = {
|
||||||
name,
|
name,
|
||||||
url: url.replace(/\/+$/, ""), // strip trailing slash
|
url: url.replace(/\/+$/, ""), // strip trailing slash
|
||||||
agentToken: agentToken ?? existing?.agentToken ?? "",
|
clientToken: clientToken ?? existing?.clientToken ?? "",
|
||||||
registeredAt: existing?.registeredAt ?? now,
|
registeredAt: existing?.registeredAt ?? now,
|
||||||
lastHeartbeat: now,
|
lastHeartbeat: now,
|
||||||
};
|
};
|
||||||
@@ -242,7 +241,7 @@ gateway.post("/register", async (c) => {
|
|||||||
|
|
||||||
gateway.delete("/register/:name", async (c) => {
|
gateway.delete("/register/:name", async (c) => {
|
||||||
const auth = c.req.header("Authorization");
|
const auth = c.req.header("Authorization");
|
||||||
if (auth !== `Bearer ${c.env.GATEWAY_SECRET}`) {
|
if (auth !== `Bearer ${c.env.WORKFLOW_DASHBOARD_SECRET}`) {
|
||||||
return c.json({ error: "unauthorized" }, 401);
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +260,7 @@ gateway.get("/endpoints", async (c) => {
|
|||||||
for (const key of list.keys) {
|
for (const key of list.keys) {
|
||||||
const record = await c.env.ENDPOINTS.get<EndpointRecord>(key.name, "json");
|
const record = await c.env.ENDPOINTS.get<EndpointRecord>(key.name, "json");
|
||||||
if (record) {
|
if (record) {
|
||||||
const doStatus = await fetchAgentSocketStatus(c.env, record.name);
|
const doStatus = await fetchClientSocketStatus(c.env, record.name);
|
||||||
const doConnected = doStatus.ok ? doStatus.connected : null;
|
const doConnected = doStatus.ok ? doStatus.connected : null;
|
||||||
endpoints.push({
|
endpoints.push({
|
||||||
name: record.name,
|
name: record.name,
|
||||||
@@ -277,25 +276,25 @@ gateway.get("/endpoints", async (c) => {
|
|||||||
|
|
||||||
app.route("/api/gateway", gateway);
|
app.route("/api/gateway", gateway);
|
||||||
|
|
||||||
// ── API proxy: /api/agents/:agent/* → WebSocket (preferred) or agent tunnel URL (dashboard auth) ──
|
// ── API proxy: /api/clients/:client/* → WebSocket (preferred) or client tunnel URL (dashboard auth) ──
|
||||||
app.all("/api/agents/:agent/*", async (c) => {
|
app.all("/api/clients/:client/*", async (c) => {
|
||||||
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
|
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
|
||||||
const agent = c.req.param("agent");
|
const client = c.req.param("client");
|
||||||
const record = await c.env.ENDPOINTS.get<EndpointRecord>(agent, "json");
|
const record = await c.env.ENDPOINTS.get<EndpointRecord>(client, "json");
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return c.json({ error: "agent not found" }, 404);
|
return c.json({ error: "client not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
const pathAfterAgent = url.pathname.replace(`/api/agents/${agent}`, "");
|
const pathAfterClient = url.pathname.replace(`/api/clients/${client}`, "");
|
||||||
const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`;
|
const targetUrl = `${record.url}/api${pathAfterClient}${url.search}`;
|
||||||
const proxyPath = `/api${pathAfterAgent}${url.search}`;
|
const proxyPath = `/api${pathAfterClient}${url.search}`;
|
||||||
const method = c.req.method;
|
const method = c.req.method;
|
||||||
const token = record.agentToken ?? "";
|
const token = record.clientToken ?? "";
|
||||||
const forwardRecord = buildForwardHeaders(c.req.raw.headers, token);
|
const forwardRecord = buildForwardHeaders(c.req.raw.headers, token);
|
||||||
|
|
||||||
const doStatus = await fetchAgentSocketStatus(c.env, agent);
|
const doStatus = await fetchClientSocketStatus(c.env, client);
|
||||||
if (doStatus.ok && doStatus.connected) {
|
if (doStatus.ok && doStatus.connected) {
|
||||||
const bodyStr = await readBodyForWsProxy(method, c.req.raw);
|
const bodyStr = await readBodyForWsProxy(method, c.req.raw);
|
||||||
const wsRequest: WsRequest = {
|
const wsRequest: WsRequest = {
|
||||||
@@ -305,7 +304,12 @@ app.all("/api/agents/:agent/*", async (c) => {
|
|||||||
headers: forwardRecord,
|
headers: forwardRecord,
|
||||||
body: bodyStr,
|
body: bodyStr,
|
||||||
};
|
};
|
||||||
const proxyResp = await fetchThroughAgentSocket(c.env, agent, c.env.GATEWAY_SECRET, wsRequest);
|
const proxyResp = await fetchThroughClientSocket(
|
||||||
|
c.env,
|
||||||
|
client,
|
||||||
|
c.env.WORKFLOW_DASHBOARD_SECRET,
|
||||||
|
wsRequest,
|
||||||
|
);
|
||||||
if (proxyResp.status !== 503) {
|
if (proxyResp.status !== 503) {
|
||||||
return new Response(proxyResp.body, {
|
return new Response(proxyResp.body, {
|
||||||
status: proxyResp.status,
|
status: proxyResp.status,
|
||||||
@@ -313,25 +317,25 @@ app.all("/api/agents/:agent/*", async (c) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await fetchAgentWithRecordHeaders(targetUrl, method, forwardRecord, bodyStr);
|
const resp = await fetchClientWithRecordHeaders(targetUrl, method, forwardRecord, bodyStr);
|
||||||
return new Response(resp.body, {
|
return new Response(resp.body, {
|
||||||
status: resp.status,
|
status: resp.status,
|
||||||
headers: resp.headers,
|
headers: resp.headers,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ error: "agent unreachable", detail: String(err) }, 502);
|
return c.json({ error: "client unreachable", detail: String(err) }, 502);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = buildDashboardProxyHeaders(c.req.raw.headers, token);
|
const headers = buildDashboardProxyHeaders(c.req.raw.headers, token);
|
||||||
try {
|
try {
|
||||||
const resp = await fetchAgentWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
|
const resp = await fetchClientWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
|
||||||
return new Response(resp.body, {
|
return new Response(resp.body, {
|
||||||
status: resp.status,
|
status: resp.status,
|
||||||
headers: resp.headers,
|
headers: resp.headers,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ error: "agent unreachable", detail: String(err) }, 502);
|
return c.json({ error: "client unreachable", detail: String(err) }, 502);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ binding = "ENDPOINTS"
|
|||||||
id = "88b118d1cfab4c049f9c1684848811a3"
|
id = "88b118d1cfab4c049f9c1684848811a3"
|
||||||
|
|
||||||
[durable_objects]
|
[durable_objects]
|
||||||
bindings = [{ name = "AGENT_SOCKET", class_name = "AgentSocket" }]
|
bindings = [{ name = "CLIENT_SOCKET", class_name = "ClientSocket" }]
|
||||||
|
|
||||||
[[migrations]]
|
[[migrations]]
|
||||||
tag = "add-agent-socket"
|
tag = "add-agent-socket"
|
||||||
new_sqlite_classes = ["AgentSocket"]
|
new_sqlite_classes = ["AgentSocket"]
|
||||||
|
|
||||||
# GATEWAY_SECRET is set via `wrangler secret put`
|
[[migrations]]
|
||||||
|
tag = "rename-agent-to-client"
|
||||||
|
renamed_classes = [{ from = "AgentSocket", to = "ClientSocket" }]
|
||||||
|
|
||||||
|
# WORKFLOW_DASHBOARD_SECRET is set via `wrangler secret put`
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# @uncaged/workflow-protocol
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- f74b482: fix: correct internal dependency versions for prerelease
|
||||||
|
- f74b482: fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fix: correct internal dependency versions for prerelease
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||||
|
|
||||||
|
## 0.4.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Add publishConfig to all packages for Gitea registry compatibility with changeset publish.
|
||||||
|
|
||||||
|
## 0.4.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Test changeset publish with Gitea registry.
|
||||||
|
|
||||||
|
## 0.4.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||||
|
|
||||||
|
## 0.4.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Fix package exports for published packages and adopt changesets for version management.
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-protocol",
|
"name": "@uncaged/workflow-protocol",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"./moderator-table.js": {
|
"./moderator-table.js": {
|
||||||
|
"bun": "./src/moderator-table.ts",
|
||||||
"types": "./dist/moderator-table.d.ts",
|
"types": "./dist/moderator-table.d.ts",
|
||||||
"import": "./src/moderator-table.ts"
|
"import": "./dist/moderator-table.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -22,5 +25,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"zod": "^4.0.0",
|
"zod": "^4.0.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type {
|
|||||||
AdapterFn,
|
AdapterFn,
|
||||||
AdvanceOutcome,
|
AdvanceOutcome,
|
||||||
AgentContext,
|
AgentContext,
|
||||||
|
AgentFn,
|
||||||
CasStore,
|
CasStore,
|
||||||
ExtractFn,
|
ExtractFn,
|
||||||
ExtractResult,
|
ExtractResult,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type WorkflowRoleSchema = Record<string, unknown>;
|
|||||||
|
|
||||||
export type WorkflowRoleDescriptor = {
|
export type WorkflowRoleDescriptor = {
|
||||||
description: string;
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
schema: WorkflowRoleSchema;
|
schema: WorkflowRoleSchema;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,6 +152,15 @@ export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promis
|
|||||||
|
|
||||||
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core agent function. Input is always {@link ThreadContext}, output is always string.
|
||||||
|
* `Opt` captures agent-specific structured options.
|
||||||
|
* Agents with no extra options use `AgentFn` (Opt defaults to void).
|
||||||
|
*/
|
||||||
|
export type AgentFn<Opt = void> = Opt extends void
|
||||||
|
? (ctx: ThreadContext) => Promise<string>
|
||||||
|
: (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||||
|
|
||||||
export type AdapterBinding = {
|
export type AdapterBinding = {
|
||||||
adapter: AdapterFn;
|
adapter: AdapterFn;
|
||||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# @uncaged/workflow-reactor
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- @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
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||||
|
|
||||||
|
## 0.4.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.4.5
|
||||||
|
|
||||||
|
## 0.4.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-reactor",
|
"name": "@uncaged/workflow-reactor",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:*"
|
"@uncaged/workflow-protocol": "workspace:^"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
@@ -21,5 +23,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"zod": "^4.0.0",
|
"zod": "^4.0.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# @uncaged/workflow-register
|
||||||
|
|
||||||
|
## 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,20 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-register",
|
"name": "@uncaged/workflow-register",
|
||||||
"version": "0.3.18",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./src/index.ts"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:*",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:*"
|
"@uncaged/workflow-util": "workspace:^"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"acorn": "^8.0.0",
|
"acorn": "^8.0.0",
|
||||||
@@ -26,5 +28,8 @@
|
|||||||
"yaml": "^2.7.1",
|
"yaml": "^2.7.1",
|
||||||
"zod": "^4.0.0",
|
"zod": "^4.0.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ export function buildDescriptor<M extends RoleMeta>(
|
|||||||
): WorkflowDescriptor {
|
): WorkflowDescriptor {
|
||||||
const roles: WorkflowDescriptor["roles"] = {};
|
const roles: WorkflowDescriptor["roles"] = {};
|
||||||
for (const [key, roleDef] of Object.entries(def.roles) as Array<
|
for (const [key, roleDef] of Object.entries(def.roles) as Array<
|
||||||
[string, { description: string; schema: z.ZodType }]
|
[string, { description: string; systemPrompt: string; schema: z.ZodType }]
|
||||||
>) {
|
>) {
|
||||||
const rawJsonSchema = z.toJSONSchema(roleDef.schema) as Record<string, unknown>;
|
const rawJsonSchema = z.toJSONSchema(roleDef.schema) as Record<string, unknown>;
|
||||||
roles[key] = {
|
roles[key] = {
|
||||||
description: roleDef.description,
|
description: roleDef.description,
|
||||||
|
systemPrompt: roleDef.systemPrompt,
|
||||||
schema: stripJsonSchemaMeta(rawJsonSchema),
|
schema: stripJsonSchemaMeta(rawJsonSchema),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamic-import a workflow bundle path (see {@link extractBundleExports} — symlink must exist first).
|
* Dynamic-import a workflow bundle path.
|
||||||
*/
|
*/
|
||||||
export async function importWorkflowBundleModule(bundlePath: string): Promise<unknown> {
|
export async function importWorkflowBundleModule(bundlePath: string): Promise<unknown> {
|
||||||
return import(pathToFileURL(bundlePath).href);
|
return import(pathToFileURL(bundlePath).href);
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
|||||||
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (spec.startsWith("@uncaged/workflow")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return isBuiltin(spec);
|
return isBuiltin(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +291,7 @@ function validateImportDeclaration(node: ImportDeclaration): string | null {
|
|||||||
return "only static string import specifiers are allowed";
|
return "only static string import specifiers are allowed";
|
||||||
}
|
}
|
||||||
if (!isAllowedImportSpecifier(spec)) {
|
if (!isAllowedImportSpecifier(spec)) {
|
||||||
return `disallowed import specifier "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`;
|
return `disallowed import specifier "${spec}" (only Node built-ins are allowed; all other dependencies must be bundled)`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -309,7 +306,7 @@ function validateExportSource(
|
|||||||
return staticMessage;
|
return staticMessage;
|
||||||
}
|
}
|
||||||
if (!isAllowedImportSpecifier(spec)) {
|
if (!isAllowedImportSpecifier(spec)) {
|
||||||
return `${disallowedPrefix} "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`;
|
return `${disallowedPrefix} "${spec}" (only Node built-ins are allowed; all other dependencies must be bundled)`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { mkdir, readlink, symlink, unlink } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
/** This module lives in `@uncaged/workflow-register/src/bundle`; grandparent dir is the package root. */
|
|
||||||
function installedWorkflowPackageDir(): string {
|
|
||||||
return fileURLToPath(new URL("../..", import.meta.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve sibling @uncaged/* package directory relative to workflow-register.
|
|
||||||
* In a monorepo workspace layout the sibling packages live next to workflow-register.
|
|
||||||
*/
|
|
||||||
function siblingPackageDir(packageName: string): string {
|
|
||||||
const registerRoot = installedWorkflowPackageDir();
|
|
||||||
return path.resolve(registerRoot, "..", packageName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureSymlink(linkDir: string, name: string, target: string): Promise<void> {
|
|
||||||
const linkPath = path.join(linkDir, name);
|
|
||||||
await mkdir(linkDir, { recursive: true });
|
|
||||||
try {
|
|
||||||
const existing = await readlink(linkPath);
|
|
||||||
const normalizedExisting = path.resolve(linkDir, existing);
|
|
||||||
if (normalizedExisting === target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await unlink(linkPath);
|
|
||||||
} catch (e) {
|
|
||||||
const errObj = e as NodeJS.ErrnoException;
|
|
||||||
if (errObj.code !== "ENOENT" && errObj.code !== "EINVAL") {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
||||||
await symlink(target, linkPath, linkType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures `<storageRoot>/node_modules/@uncaged/*` symlinks point at installed packages
|
|
||||||
* so workflow bundles loaded from `<storageRoot>/bundles/*.esm.js` can resolve their imports.
|
|
||||||
*/
|
|
||||||
export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise<void> {
|
|
||||||
const linkDir = path.join(storageRoot, "node_modules", "@uncaged");
|
|
||||||
|
|
||||||
const packages = [
|
|
||||||
{ name: "workflow", dir: siblingPackageDir("workflow") },
|
|
||||||
{ name: "workflow-runtime", dir: siblingPackageDir("workflow-runtime") },
|
|
||||||
{ name: "workflow-cas", dir: siblingPackageDir("workflow-cas") },
|
|
||||||
{ name: "workflow-protocol", dir: siblingPackageDir("workflow-protocol") },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pkg of packages) {
|
|
||||||
await ensureSymlink(linkDir, pkg.name, pkg.dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
import type { WorkflowFn } from "@uncaged/workflow-protocol";
|
import type { WorkflowFn } from "@uncaged/workflow-protocol";
|
||||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||||
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||||
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
import type { ExtractedBundleExports } from "./types.js";
|
||||||
import type { ExtractBundleExportsOptions, ExtractedBundleExports } from "./types.js";
|
|
||||||
import { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
import { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||||
|
|
||||||
/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */
|
/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */
|
||||||
export async function extractBundleExports(
|
export async function extractBundleExports(
|
||||||
bundlePath: string,
|
bundlePath: string,
|
||||||
options: ExtractBundleExportsOptions = { storageRoot: null },
|
|
||||||
): Promise<Result<ExtractedBundleExports, string>> {
|
): Promise<Result<ExtractedBundleExports, string>> {
|
||||||
let modUnknown: unknown;
|
let modUnknown: unknown;
|
||||||
try {
|
try {
|
||||||
if (options.storageRoot !== null) {
|
|
||||||
await ensureUncagedWorkflowSymlink(options.storageRoot);
|
|
||||||
}
|
|
||||||
// Dynamic import required: user bundle path resolved at runtime
|
|
||||||
modUnknown = await importWorkflowBundleModule(bundlePath);
|
modUnknown = await importWorkflowBundleModule(bundlePath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
export { buildDescriptor } from "./build-descriptor.js";
|
export { buildDescriptor } from "./build-descriptor.js";
|
||||||
export { importWorkflowBundleModule } from "./bundle-import-env.js";
|
export { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||||
export { validateWorkflowBundle } from "./bundle-validator.js";
|
export { validateWorkflowBundle } from "./bundle-validator.js";
|
||||||
export { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
|
||||||
export { extractBundleExports } from "./extract-bundle-exports.js";
|
export { extractBundleExports } from "./extract-bundle-exports.js";
|
||||||
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
||||||
export type {
|
export type {
|
||||||
ExtractBundleExportsOptions,
|
|
||||||
ExtractedBundleExports,
|
ExtractedBundleExports,
|
||||||
WorkflowBundleValidationInput,
|
WorkflowBundleValidationInput,
|
||||||
WorkflowDescriptor,
|
WorkflowDescriptor,
|
||||||
|
|||||||
@@ -20,8 +20,3 @@ export type ExtractedBundleExports = {
|
|||||||
run: WorkflowFn;
|
run: WorkflowFn;
|
||||||
descriptor: WorkflowDescriptor;
|
descriptor: WorkflowDescriptor;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExtractBundleExportsOptions = {
|
|
||||||
/** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */
|
|
||||||
storageRoot: string | null;
|
|
||||||
};
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user